diff --git a/.changeset/shaggy-toes-attack.md b/.changeset/shaggy-toes-attack.md new file mode 100644 index 00000000..a5e180ca --- /dev/null +++ b/.changeset/shaggy-toes-attack.md @@ -0,0 +1,5 @@ +--- +'@formwerk/core': patch +--- + +feat: add standard schema support diff --git a/packages/core/package.json b/packages/core/package.json index f89c1c73..c9f30dd8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,8 @@ "dist/*.d.ts" ], "dependencies": { + "@standard-schema/spec": "1.0.0-beta.1", + "@standard-schema/utils": "^0.1.1", "klona": "^2.0.6", "type-fest": "^4.26.0", "vue": ">=3.5.0" diff --git a/packages/core/src/types/forms.ts b/packages/core/src/types/forms.ts index cf33d907..544f99c9 100644 --- a/packages/core/src/types/forms.ts +++ b/packages/core/src/types/forms.ts @@ -1,18 +1,29 @@ import { Schema, Simplify } from 'type-fest'; +import type { v1 } from '@standard-schema/spec'; import { FormObject } from './common'; import { Path } from './paths'; -import { TypedSchemaError } from './typedSchema'; import { FormValidationMode } from '../useForm/formContext'; +export type StandardIssue = v1.StandardIssue; + +export type StandardSchema = v1.StandardSchema; + +export type FormSchema = StandardSchema; + export type TouchedSchema = Simplify>; export type DisabledSchema = Partial, boolean>>; export type ErrorsSchema = Partial, string[]>>; +export type IssueCollection = { + path: string; + messages: string[]; +}; + type BaseValidationResult = { isValid: boolean; - errors: TypedSchemaError[]; + errors: IssueCollection[]; }; export interface ValidationResult extends BaseValidationResult { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a9297b54..b0bb8809 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,4 +1,3 @@ export * from './common'; export * from './paths'; export * from './forms'; -export * from './typedSchema'; diff --git a/packages/core/src/types/typedSchema.ts b/packages/core/src/types/typedSchema.ts deleted file mode 100644 index d68bda89..00000000 --- a/packages/core/src/types/typedSchema.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FormObject } from './common'; - -export interface TypedSchemaError { - path: string; - messages: string[]; -} - -export interface TypedSchemaContext { - formData: FormObject; -} - -export interface TypedSchema { - parse(values: TInput, context?: TypedSchemaContext): Promise<{ output?: TOutput; errors: TypedSchemaError[] }>; - defaults?(values: TInput): TInput; -} - -export type InferOutput = - TSchema extends TypedSchema ? TOutput : never; - -export type InferInput = TSchema extends TypedSchema ? TInput : never; diff --git a/packages/core/src/useCheckbox/useCheckbox.ts b/packages/core/src/useCheckbox/useCheckbox.ts index e618e43e..716a4d77 100644 --- a/packages/core/src/useCheckbox/useCheckbox.ts +++ b/packages/core/src/useCheckbox/useCheckbox.ts @@ -14,7 +14,7 @@ import { NormalizedProps, Reactivify, RovingTabIndex, - TypedSchema, + StandardSchema, } from '../types'; import { useLabel } from '../a11y/useLabel'; import { CheckboxGroupContext, CheckboxGroupKey } from './useCheckboxGroup'; @@ -38,7 +38,7 @@ export interface CheckboxProps { indeterminate?: boolean; standalone?: boolean; - schema?: TypedSchema; + schema?: StandardSchema; disableHtmlValidation?: boolean; } diff --git a/packages/core/src/useCheckbox/useCheckboxGroup.spec.ts b/packages/core/src/useCheckbox/useCheckboxGroup.spec.ts index 2b8f977f..c1796ce1 100644 --- a/packages/core/src/useCheckbox/useCheckboxGroup.spec.ts +++ b/packages/core/src/useCheckbox/useCheckboxGroup.spec.ts @@ -4,9 +4,7 @@ import { CheckboxProps, useCheckbox } from './useCheckbox'; import { fireEvent, render, screen } from '@testing-library/vue'; import { axe } from 'vitest-axe'; import { describe } from 'vitest'; -import { flush } from '@test-utils/flush'; -import { TypedSchema } from '../types'; -import { renderSetup } from '../../../test-utils/src'; +import { flush, renderSetup, defineStandardSchema } from '@test-utils/index'; const createGroup = ( props: CheckboxGroupProps, @@ -224,16 +222,13 @@ describe('validation', () => { }); test('should revalidate when value changes', async () => { - const schema: TypedSchema = { - parse: value => { - return value?.length >= 2 - ? Promise.resolve({ output: value, errors: [] }) - : Promise.resolve({ - output: value, - errors: [{ messages: ['You must select two or more options'], path: '' }], - }); - }, - }; + const schema = defineStandardSchema(async ({ value }) => { + return (value as any)?.length >= 2 + ? Promise.resolve({ value: value }) + : Promise.resolve({ + issues: [{ message: 'You must select two or more options', path: [''] }], + }); + }); const CheckboxGroup = createGroup({ label: 'Group', schema }); const Checkbox = createCheckbox(CustomBase); diff --git a/packages/core/src/useCheckbox/useCheckboxGroup.ts b/packages/core/src/useCheckbox/useCheckboxGroup.ts index 8982b46f..dbf3b67c 100644 --- a/packages/core/src/useCheckbox/useCheckboxGroup.ts +++ b/packages/core/src/useCheckbox/useCheckboxGroup.ts @@ -8,7 +8,7 @@ import { Direction, Reactivify, Arrayable, - TypedSchema, + StandardSchema, } from '../types'; import { useUniqId, @@ -72,7 +72,7 @@ export interface CheckboxGroupProps { readonly?: boolean; required?: boolean; - schema?: TypedSchema>; + schema?: StandardSchema>; disableHtmlValidation?: boolean; } diff --git a/packages/core/src/useForm/formContext.ts b/packages/core/src/useForm/formContext.ts index c05d17f6..0f23d720 100644 --- a/packages/core/src/useForm/formContext.ts +++ b/packages/core/src/useForm/formContext.ts @@ -6,9 +6,9 @@ import { Path, PathValue, TouchedSchema, - TypedSchema, + StandardSchema, ErrorsSchema, - TypedSchemaError, + IssueCollection, } from '../types'; import { cloneDeep, isEqual, normalizeArrayable } from '../utils/common'; import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path'; @@ -36,7 +36,7 @@ export interface BaseFormContext { getFieldErrors>(path: TPath): string[]; setFieldErrors>(path: TPath, message: Arrayable): void; getValidationMode(): FormValidationMode; - getErrors: () => TypedSchemaError[]; + getErrors: () => IssueCollection[]; clearErrors: (path?: string) => void; hasErrors: () => boolean; getValues: () => TForm; @@ -55,7 +55,7 @@ export interface FormContextCreateOptions; disabled: DisabledSchema; errors: Ref>; - schema: TypedSchema | undefined; + schema: StandardSchema | undefined; snapshots: { values: FormSnapshot; touched: FormSnapshot>; @@ -134,9 +134,9 @@ export function createFormContext Array.isArray(l) && l.length > 0); } - function getErrors(): TypedSchemaError[] { + function getErrors(): IssueCollection[] { return Object.entries(errors.value) - .map(([key, value]) => ({ path: key, messages: value as string[] })) + .map(([key, value]) => ({ path: key, messages: value as string[] })) .filter(e => e.messages.length > 0); } diff --git a/packages/core/src/useForm/formSnapshot.ts b/packages/core/src/useForm/formSnapshot.ts index e3d3883e..d0488bbc 100644 --- a/packages/core/src/useForm/formSnapshot.ts +++ b/packages/core/src/useForm/formSnapshot.ts @@ -1,10 +1,10 @@ import { Ref, shallowRef, toValue } from 'vue'; -import { FormObject, MaybeGetter, MaybeAsync, TypedSchema } from '../types'; +import { FormObject, MaybeGetter, MaybeAsync, StandardSchema } from '../types'; import { cloneDeep, isPromise } from '../utils/common'; interface FormSnapshotOptions { onAsyncInit?: (values: TForm) => void; - schema?: TypedSchema; + schema?: StandardSchema; } export interface FormSnapshot { @@ -23,13 +23,13 @@ export function useFormSnapshots { - const inits = opts?.schema?.defaults?.(resolved) ?? resolved; + const inits = resolved; initials.value = cloneDeep(inits || {}) as TForm; originals.value = cloneDeep(inits || {}) as TForm; opts?.onAsyncInit?.(cloneDeep(inits)); }); } else { - const inits = opts?.schema?.defaults?.(provided || ({} as TForm)) ?? provided; + const inits = provided; initials.value = cloneDeep(inits || {}) as TForm; originals.value = cloneDeep(inits || {}) as TForm; } diff --git a/packages/core/src/useForm/useForm.spec.ts b/packages/core/src/useForm/useForm.spec.ts index d641e853..f837f55e 100644 --- a/packages/core/src/useForm/useForm.spec.ts +++ b/packages/core/src/useForm/useForm.spec.ts @@ -1,11 +1,11 @@ -import { flush, renderSetup } from '@test-utils/index'; +import { flush, renderSetup, defineStandardSchema } from '@test-utils/index'; import { useForm } from './useForm'; import { useFormField } from '../useFormField'; import { Component, nextTick, Ref, ref } from 'vue'; import { useInputValidity } from '../validation/useInputValidity'; import { fireEvent, render, screen } from '@testing-library/vue'; -import { TypedSchema } from '../types'; import { useTextField } from '../useTextField'; +import { StandardSchema } from '../types'; describe('form values', () => { test('it initializes form values', async () => { @@ -42,7 +42,7 @@ describe('form values', () => { test('setValues replaces form values by default', async () => { const { values, setValues } = await renderSetup(() => { - return useForm>({ initialValues: { x: 'y' } }); + return useForm({ initialValues: { x: 'y' } as Record }); }); setValues({ foo: 'baz' }); @@ -52,7 +52,7 @@ describe('form values', () => { test('setValues can merge form values if specified', async () => { const { values, setValues } = await renderSetup(() => { - return useForm>({ initialValues: { x: 'y' } }); + return useForm({ initialValues: { x: 'y' } as Record }); }); setValues({ foo: 'baz' }, { behavior: 'merge' }); @@ -569,13 +569,13 @@ describe('form validation', () => { }); }); - describe('TypedSchema', () => { + describe('Standard Schema', () => { function createInputComponent(): Component { return { inheritAttrs: false, setup: (_, { attrs }) => { const name = (attrs.name || 'test') as string; - const schema = attrs.schema as TypedSchema; + const schema = attrs.schema as StandardSchema; const { errorMessage, inputProps } = useTextField({ name, label: name, schema }); return { errorMessage: errorMessage, inputProps, name }; @@ -589,13 +589,11 @@ describe('form validation', () => { test('prevent submission if typed schema has errors', async () => { const handler = vi.fn(); - const schema: TypedSchema = { - async parse() { - return { - errors: [{ path: 'test', messages: ['error'] }], - }; - }, - }; + const schema = defineStandardSchema(() => { + return { + issues: [{ path: ['test'], message: 'error' }], + }; + }); await render({ setup() { @@ -620,13 +618,11 @@ describe('form validation', () => { test('sets field errors', async () => { const handler = vi.fn(); let shouldError = true; - const schema: TypedSchema = { - async parse() { - return { - errors: shouldError ? [{ path: 'test', messages: ['error'] }] : [], - }; - }, - }; + const schema = defineStandardSchema(() => { + return { + issues: shouldError ? [{ path: ['test'], message: 'error' }] : [], + }; + }); await render({ components: { Child: createInputComponent() }, @@ -660,13 +656,11 @@ describe('form validation', () => { test('clears errors on successful submission', async () => { const handler = vi.fn(); - const schema: TypedSchema = { - async parse() { - return { - errors: [], - }; - }, - }; + const schema = defineStandardSchema(() => { + return { + issues: [], + }; + }); await render({ components: { Child: createInputComponent() }, @@ -675,7 +669,6 @@ describe('form validation', () => { schema, }); - // @ts-expect-error - We don't care about our fake form here setFieldErrors('test', 'error'); return { getError, onSubmit: handleSubmit(v => handler(v.toJSON())) }; @@ -701,17 +694,14 @@ describe('form validation', () => { test('parses values which is used on submission', async () => { const handler = vi.fn(); - const schema: TypedSchema = { - async parse() { - return { - errors: [], - output: { - test: true, - foo: 'bar', - }, - }; - }, - }; + const schema = defineStandardSchema<{ test: boolean; foo: string }>(() => { + return { + value: { + test: true, + foo: 'bar', + }, + }; + }); await render({ components: { Child: createInputComponent() }, @@ -720,7 +710,6 @@ describe('form validation', () => { schema, }); - // @ts-expect-error - We don't care about our fake form here setFieldErrors('test', 'error'); return { getError, onSubmit: handleSubmit(v => handler(v.toJSON())) }; @@ -742,13 +731,11 @@ describe('form validation', () => { }); test('re-validates on field value change', async () => { - const schema: TypedSchema<{ test: string }> = { - async parse(values) { - return { - errors: !values.test ? [{ path: 'test', messages: ['error'] }] : [], - }; - }, - }; + const schema = defineStandardSchema<{ test: string }>(({ value }) => { + return { + issues: !(value as any).test ? [{ path: ['test'], message: 'error' }] : [], + }; + }); await render({ components: { Child: createInputComponent() }, @@ -776,17 +763,18 @@ describe('form validation', () => { expect(screen.getByTestId('form-err').textContent).toBe(''); }); - test('initializes with default values', async () => { + // FIXME: Standard schema does not support defaults yet. + test.fails('initializes with default values', async () => { const { values } = await renderSetup(() => { return useForm({ - schema: { - defaults: () => ({ test: 'foo' }), - async parse() { - return { - errors: [], - }; - }, - }, + // schema: { + // defaults: () => ({ test: 'foo' }), + // async parse() { + // return { + // errors: [], + // }; + // }, + // }, }); }); @@ -795,21 +783,17 @@ describe('form validation', () => { test('combines errors from field-level schemas', async () => { const handler = vi.fn(); - const schema: TypedSchema = { - async parse() { - return { - errors: [{ path: 'test', messages: ['error'] }], - }; - }, - }; + const schema = defineStandardSchema<{ test: string }>(() => { + return { + issues: [{ path: ['test'], message: 'error' }], + }; + }); - const fieldSchema: TypedSchema = { - async parse() { - return { - errors: [{ path: 'field', messages: ['field error'] }], - }; - }, - }; + const fieldSchema = defineStandardSchema(() => { + return { + issues: [{ path: ['field'], message: 'field error' }], + }; + }); await render({ components: { Child: createInputComponent() }, @@ -841,16 +825,14 @@ describe('form validation', () => { }); test('form reset re-validates by default', async () => { - const schema: TypedSchema<{ test: string }> = { - async parse() { - return { - errors: [{ path: 'test', messages: ['error'] }], - }; - }, - }; + const schema = defineStandardSchema<{ test: string }>(() => { + return { + issues: [{ path: ['test'], message: 'error' }], + }; + }); const { reset, getError } = await renderSetup(() => { - return useForm<{ test: string }>({ + return useForm({ schema, }); }); @@ -863,16 +845,14 @@ describe('form validation', () => { test('form reset revalidation can be disabled', async () => { let wasReset = false; - const schema: TypedSchema<{ test: string }> = { - async parse() { - return { - errors: [{ path: 'test', messages: wasReset ? ['reset'] : ['error'] }], - }; - }, - }; + const schema = defineStandardSchema<{ test: string }>(() => { + return { + issues: [{ path: ['test'], message: wasReset ? 'reset' : 'error' }], + }; + }); const { reset, getError } = await renderSetup(() => { - return useForm<{ test: string }>({ + return useForm({ schema, }); }); diff --git a/packages/core/src/useForm/useForm.ts b/packages/core/src/useForm/useForm.ts index 663fe063..1ddb79b9 100644 --- a/packages/core/src/useForm/useForm.ts +++ b/packages/core/src/useForm/useForm.ts @@ -1,4 +1,5 @@ import { computed, InjectionKey, onMounted, provide, reactive, readonly, Ref, ref } from 'vue'; +import { InferInput, InferOutput } from '@standard-schema/spec'; import { cloneDeep, isEqual, useUniqId } from '../utils/common'; import { FormObject, @@ -8,10 +9,11 @@ import { DisabledSchema, ErrorsSchema, Path, - TypedSchema, ValidationResult, FormValidationResult, GroupValidationResult, + FormSchema, + StandardSchema, } from '../types'; import { createFormContext, BaseFormContext } from './formContext'; import { FormTransactionManager, useFormTransactions } from './useFormTransactions'; @@ -21,18 +23,19 @@ import { findLeaf } from '../utils/path'; import { getConfig } from '../config'; import { FieldTypePrefixes } from '../constants'; import { appendToFormData, clearFormData } from '../utils/formData'; +import { PartialDeep } from 'type-fest'; -export interface FormOptions { +export interface FormOptions> { id: string; - initialValues: MaybeGetter>; - initialTouched: TouchedSchema; - schema: TypedSchema; + initialValues: MaybeGetter>; + initialTouched: TouchedSchema; + schema: TSchema; disableHtmlValidation: boolean; } -export interface FormContext - extends BaseFormContext, - FormTransactionManager { +export interface FormContext + extends BaseFormContext, + FormTransactionManager { requestValidation(): Promise>; onSubmitAttempt(cb: () => void): void; onValidationDone(cb: () => void): void; @@ -48,28 +51,30 @@ export interface FormDomProps { export const FormKey: InjectionKey> = Symbol('Formwerk FormKey'); -export function useForm( - opts?: Partial>, -) { +export function useForm< + TSchema extends FormSchema, + TInput extends FormObject = InferInput, + TOutput extends FormObject = InferOutput, +>(opts?: Partial>) { const touchedSnapshot = useFormSnapshots(opts?.initialTouched); - const valuesSnapshot = useFormSnapshots(opts?.initialValues, { + const valuesSnapshot = useFormSnapshots(opts?.initialValues, { onAsyncInit, - schema: opts?.schema, + schema: opts?.schema as StandardSchema, }); const id = opts?.id || useUniqId(FieldTypePrefixes.Form); const isHtmlValidationDisabled = () => opts?.disableHtmlValidation ?? getConfig().disableHtmlValidation; - const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as TForm; - const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema; - const disabled = {} as DisabledSchema; - const errors = ref({}) as Ref>; + const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as PartialDeep; + const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema; + const disabled = {} as DisabledSchema; + const errors = ref({}) as Ref>; - const ctx = createFormContext({ + const ctx = createFormContext({ id, - values, + values: values as TInput, touched, disabled, - schema: opts?.schema, + schema: opts?.schema as StandardSchema, errors, snapshots: { values: valuesSnapshot, @@ -89,25 +94,25 @@ export function useForm(ctx, { + const { actions, isSubmitting, ...privateActions } = useFormActions(ctx, { disabled, - schema: opts?.schema, + schema: opts?.schema as StandardSchema, }); function getErrors() { return ctx.getErrors(); } - function getError>(path: TPath): string | undefined { + function getError>(path: TPath): string | undefined { return ctx.getFieldErrors(path)[0]; } - function displayError(path: Path) { + function displayError(path: Path) { return ctx.isFieldTouched(path) ? getError(path) : undefined; } @@ -116,7 +121,7 @@ export function useForm); + } as FormContext); if (ctx.getValidationMode() === 'schema') { onMounted(privateActions.requestValidation); diff --git a/packages/core/src/useForm/useFormActions.ts b/packages/core/src/useForm/useFormActions.ts index 9d746ca3..e57a4d8b 100644 --- a/packages/core/src/useForm/useFormActions.ts +++ b/packages/core/src/useForm/useFormActions.ts @@ -5,9 +5,9 @@ import { FormValidationResult, MaybeAsync, Path, + IssueCollection, + StandardSchema, TouchedSchema, - TypedSchema, - TypedSchemaError, } from '../types'; import { createEventDispatcher } from '../utils/events'; import { BaseFormContext, SetValueOptions } from './formContext'; @@ -22,7 +22,7 @@ export interface ResetState { } export interface FormActionsOptions { - schema: TypedSchema | undefined; + schema: StandardSchema | undefined; disabled: DisabledSchema; } @@ -99,7 +99,7 @@ export function useFormActions, entry.messages); } diff --git a/packages/core/src/useFormField/useFormField.spec.ts b/packages/core/src/useFormField/useFormField.spec.ts index a25a257f..852884aa 100644 --- a/packages/core/src/useFormField/useFormField.spec.ts +++ b/packages/core/src/useFormField/useFormField.spec.ts @@ -1,7 +1,6 @@ -import { renderSetup } from '@test-utils/index'; +import { renderSetup, defineStandardSchema } from '@test-utils/index'; import { useFormField } from './useFormField'; import { useForm } from '../useForm/useForm'; -import { nextTick } from 'vue'; test('it initializes the field value', async () => { const { fieldValue } = await renderSetup(() => { @@ -120,11 +119,9 @@ test('can have a typed schema for validation', async () => { const { validate, errors } = await renderSetup(() => { return useFormField({ initialValue: 'bar', - schema: { - parse: async () => { - return { errors: [{ messages: ['error'], path: 'field' }] }; - }, - }, + schema: defineStandardSchema(async () => { + return { issues: [{ message: 'error', path: ['field'] }] }; + }), }); }); @@ -133,20 +130,20 @@ test('can have a typed schema for validation', async () => { expect(errors.value).toEqual(['error']); }); -test('can have a typed schema for initial value', async () => { - const { fieldValue } = await renderSetup(() => { - return useFormField({ - schema: { - parse: async () => { - return { errors: [] }; - }, - defaults(value) { - return value || 'default'; - }, - }, - }); - }); - - await nextTick(); - expect(fieldValue.value).toEqual('default'); -}); +// test('can have a typed schema for initial value', async () => { +// const { fieldValue } = await renderSetup(() => { +// return useFormField({ +// schema: { +// parse: async () => { +// return { errors: [] }; +// }, +// defaults(value) { +// return value || 'default'; +// }, +// }, +// }); +// }); + +// await nextTick(); +// expect(fieldValue.value).toEqual('default'); +// }); diff --git a/packages/core/src/useFormField/useFormField.ts b/packages/core/src/useFormField/useFormField.ts index 0d9cf8f3..f2aa1893 100644 --- a/packages/core/src/useFormField/useFormField.ts +++ b/packages/core/src/useFormField/useFormField.ts @@ -1,8 +1,8 @@ import { computed, inject, MaybeRefOrGetter, nextTick, readonly, Ref, shallowRef, toValue, watch } from 'vue'; import { FormContext, FormKey } from '../useForm/useForm'; -import { Arrayable, Getter, TypedSchema, ValidationResult } from '../types'; +import { Arrayable, Getter, StandardSchema, ValidationResult } from '../types'; import { useSyncModel } from '../reactivity/useModelSync'; -import { cloneDeep, isEqual, normalizeArrayable, tryOnScopeDispose } from '../utils/common'; +import { cloneDeep, isEqual, normalizeArrayable, combineIssues, tryOnScopeDispose } from '../utils/common'; import { FormGroupKey } from '../useFormGroup'; import { useErrorDisplay } from './useErrorDisplay'; import { usePathPrefixer } from '../helpers/usePathPrefixer'; @@ -14,7 +14,7 @@ interface FormFieldOptions { syncModel: boolean; modelName: string; disabled: MaybeRefOrGetter; - schema: TypedSchema; + schema: StandardSchema; } export type FormField = { @@ -24,7 +24,7 @@ export type FormField = { isValid: Ref; errors: Ref; errorMessage: Ref; - schema: TypedSchema | undefined; + schema: StandardSchema | undefined; validate(mutate?: boolean): Promise; getPath: Getter; getName: Getter; @@ -43,7 +43,7 @@ export function useFormField(opts?: Partial(opts?: Partial e.messages).flat()); } @@ -94,7 +97,7 @@ export function useFormField(opts?: Partial ({ messages: e.messages, path: getPath() || e.path })), + errors, }); } diff --git a/packages/core/src/useFormGroup/useFormGroup.spec.ts b/packages/core/src/useFormGroup/useFormGroup.spec.ts index 277ed34a..df3cfc40 100644 --- a/packages/core/src/useFormGroup/useFormGroup.spec.ts +++ b/packages/core/src/useFormGroup/useFormGroup.spec.ts @@ -1,19 +1,19 @@ import { renderSetup } from '@test-utils/renderSetup'; import { Component } from 'vue'; import { useFormGroup } from './useFormGroup'; -import { TypedSchema } from '../types'; import { useTextField } from '../useTextField'; import { useForm } from '../useForm'; import { fireEvent, render, screen } from '@testing-library/vue'; -import { flush } from '@test-utils/flush'; +import { flush, defineStandardSchema } from '@test-utils/index'; import { configure } from '../config'; +import { StandardSchema } from '../types'; function createInputComponent(): Component { return { inheritAttrs: false, setup: (_, { attrs }) => { const name = (attrs.name || 'test') as string; - const schema = attrs.schema as TypedSchema; + const schema = attrs.schema as StandardSchema; const { errorMessage, inputProps } = useTextField({ name, label: name, @@ -35,7 +35,7 @@ function createGroupComponent(fn?: (fg: ReturnType) => void inheritAttrs: false, setup: (_, { attrs }) => { const name = (attrs.name || 'test') as string; - const schema = attrs.schema as TypedSchema; + const schema = attrs.schema as StandardSchema; const fg = useFormGroup({ name, label: name, schema, disableHtmlValidation: attrs.disableHtmlValidation as any }); fn?.(fg); @@ -146,13 +146,11 @@ test('tracks its touched state', async () => { test('tracks its valid state', async () => { const groups: ReturnType[] = []; - const schema: TypedSchema = { - async parse(value) { - return { - errors: value ? [] : [{ path: 'groupTest.field1', messages: ['error'] }], - }; - }, - }; + const schema = defineStandardSchema(({ value }) => { + return { + issues: value ? [] : [{ path: ['groupTest', 'field1'], message: 'error' }], + }; + }); await render({ components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, @@ -187,13 +185,11 @@ test('tracks its valid state', async () => { test('validates with a typed schema', async () => { let form!: ReturnType; const groups: ReturnType[] = []; - const schema: TypedSchema<{ field: string }> = { - async parse(value) { - return { - errors: value.field ? [] : [{ path: 'field', messages: ['error'] }], - }; - }, - }; + const schema = defineStandardSchema(({ value }) => { + return { + issues: (value as any).field ? [] : [{ message: 'error', path: ['field'] }], + }; + }); await render({ components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, @@ -225,21 +221,17 @@ test('validates with a typed schema', async () => { test('validation combines schema with form schema', async () => { let form!: ReturnType; const groups: ReturnType[] = []; - const groupSchema: TypedSchema<{ field: string }> = { - async parse(value) { - return { - errors: value.field ? [] : [{ path: 'field', messages: ['error'] }], - }; - }, - }; + const groupSchema = defineStandardSchema<{ field: string }>(({ value }) => { + return { + issues: (value as any).field ? [] : [{ message: 'error', path: ['field'] }], + }; + }); - const formSchema: TypedSchema<{ other: string }> = { - async parse(value) { - return { - errors: value.other ? [] : [{ path: 'other', messages: ['error'] }], - }; - }, - }; + const formSchema = defineStandardSchema<{ other: string }>(({ value }) => { + return { + issues: (value as any).other ? [] : [{ message: 'error', path: ['other'] }], + }; + }); await render({ components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, @@ -278,21 +270,17 @@ test('validation combines schema with form schema', async () => { test('validation cascades', async () => { let form!: ReturnType; const groups: ReturnType[] = []; - const groupSchema: TypedSchema<{ field: string }> = { - async parse(value) { - return { - errors: value.field === 'valid' ? [] : [{ path: 'field', messages: ['error'] }], - }; - }, - }; + const groupSchema = defineStandardSchema<{ field: string }>(({ value }) => { + return { + issues: (value as any).field === 'valid' ? [] : [{ message: 'error', path: ['field'] }], + }; + }); - const formSchema: TypedSchema<{ other: string }> = { - async parse(value) { - return { - errors: value.other === 'valid' ? [] : [{ path: 'other', messages: ['error'] }], - }; - }, - }; + const formSchema = defineStandardSchema<{ other: string }>(({ value }) => { + return { + issues: (value as any).other === 'valid' ? [] : [{ message: 'error', path: ['other'] }], + }; + }); await render({ components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, @@ -340,14 +328,12 @@ test('validation cascades', async () => { test('submission combines group data with form data', async () => { const submitHandler = vi.fn(); - const groupSchema: TypedSchema<{ first: string }> = { - async parse() { - return { - output: { first: 'wow', second: 'how' }, - errors: [], - }; - }, - }; + const groupSchema = defineStandardSchema<{ first: string }>(() => { + return { + value: { first: 'wow', second: 'how' }, + }; + }); + await render({ components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, setup() { diff --git a/packages/core/src/useFormGroup/useFormGroup.ts b/packages/core/src/useFormGroup/useFormGroup.ts index 1b68fb62..d44f00a0 100644 --- a/packages/core/src/useFormGroup/useFormGroup.ts +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -6,7 +6,7 @@ import { FormObject, GroupValidationResult, Reactivify, - TypedSchema, + StandardSchema, ValidationResult, } from '../types'; import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; @@ -20,7 +20,7 @@ import { createPathPrefixer } from '../helpers/usePathPrefixer'; export interface FormGroupProps { name: string; label?: string; - schema?: TypedSchema; + schema?: StandardSchema; disableHtmlValidation?: boolean; } @@ -62,7 +62,7 @@ export function useFormGroup { }); test('should revalidate when increment/decrement buttons', async () => { - const schema: TypedSchema = { - parse: value => { - return Number(value) > 1 - ? Promise.resolve({ errors: [] }) - : Promise.resolve({ errors: [{ messages: ['Value must be greater than 1'], path: '' }] }); - }, - }; + const schema = defineStandardSchema(({ value }) => { + return Number(value) > 1 + ? { value: Number(value) } + : { issues: [{ message: 'Value must be greater than 1', path: [] }] }; + }); await render(makeTest({ schema })); await flush(); @@ -148,13 +145,11 @@ describe('validation', () => { }); test('should revalidate when increment/decrement with arrows', async () => { - const schema: TypedSchema = { - parse: value => { - return Number(value) > 1 - ? Promise.resolve({ output: value, errors: [] }) - : Promise.resolve({ output: value, errors: [{ messages: ['Value must be greater than 1'], path: '' }] }); - }, - }; + const schema = defineStandardSchema(({ value }) => { + return Number(value) > 1 + ? { value: Number(value) } + : { issues: [{ message: 'Value must be greater than 1', path: [] }] }; + }); await render(makeTest({ schema })); await fireEvent.keyDown(screen.getByLabelText(label), { code: 'ArrowUp' }); diff --git a/packages/core/src/useNumberField/useNumberField.ts b/packages/core/src/useNumberField/useNumberField.ts index 8f3f5258..d34f5379 100644 --- a/packages/core/src/useNumberField/useNumberField.ts +++ b/packages/core/src/useNumberField/useNumberField.ts @@ -16,7 +16,8 @@ import { AriaValidatableProps, Numberish, Reactivify, -} from '../types/common'; + StandardSchema, +} from '../types'; import { useInputValidity } from '../validation/useInputValidity'; import { useLabel } from '../a11y/useLabel'; import { useNumberParser } from '../i18n/useNumberParser'; @@ -24,7 +25,6 @@ import { useSpinButton } from '../useSpinButton'; import { useLocale } from '../i18n'; import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; -import { TypedSchema } from '../types'; import { exposeField } from '../utils/exposers'; import { useEventListener } from '../helpers/useEventListener'; @@ -63,7 +63,7 @@ export interface NumberFieldProps { formatOptions?: Intl.NumberFormatOptions; - schema?: TypedSchema; + schema?: StandardSchema; disableWheel?: boolean; disableHtmlValidation?: boolean; diff --git a/packages/core/src/useRadio/useRadioGroup.spec.ts b/packages/core/src/useRadio/useRadioGroup.spec.ts index 327dcd1d..a9810977 100644 --- a/packages/core/src/useRadio/useRadioGroup.spec.ts +++ b/packages/core/src/useRadio/useRadioGroup.spec.ts @@ -4,8 +4,7 @@ import { RadioProps, useRadio } from './useRadio'; import { fireEvent, render, screen } from '@testing-library/vue'; import { axe } from 'vitest-axe'; import { describe } from 'vitest'; -import { flush } from '@test-utils/flush'; -import { TypedSchema } from '../..'; +import { flush, defineStandardSchema } from '@test-utils/index'; const createGroup = (props: RadioGroupProps): Component => { return defineComponent({ @@ -443,13 +442,12 @@ describe('validation', () => { }); test('should revalidate when value changes via arrow keys', async () => { - const schema: TypedSchema = { - parse: value => { - return Number(value) > 2 - ? Promise.resolve({ output: value, errors: [] }) - : Promise.resolve({ output: value, errors: [{ messages: ['Value must be greater than 2'], path: '' }] }); - }, - }; + const schema = defineStandardSchema(({ value }) => { + return Number(value) > 2 + ? { value: String(value) } + : { issues: [{ message: 'Value must be greater than 2', path: [] }] }; + }); + const RadioGroup = createGroup({ label: 'Group', required: true, schema }); const RadioInput = createRadio(CustomBase); @@ -473,13 +471,12 @@ describe('validation', () => { }); test('should revalidate when value changes via clicks', async () => { - const schema: TypedSchema = { - parse: value => { - return Number(value) > 2 - ? Promise.resolve({ output: value, errors: [] }) - : Promise.resolve({ output: value, errors: [{ messages: ['Value must be greater than 2'], path: '' }] }); - }, - }; + const schema = defineStandardSchema(({ value }) => { + return Number(value) > 2 + ? { value: String(value) } + : { issues: [{ message: 'Value must be greater than 2', path: [] }] }; + }); + const RadioGroup = createGroup({ label: 'Group', required: true, schema }); const RadioInput = createRadio(CustomBase); diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index b23ed573..385a2a67 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -8,7 +8,7 @@ import { AriaValidatableProps, Direction, Reactivify, - TypedSchema, + StandardSchema, } from '../types'; import { useUniqId, @@ -61,7 +61,7 @@ export interface RadioGroupProps { readonly?: boolean; required?: boolean; - schema?: TypedSchema; + schema?: StandardSchema; disableHtmlValidation?: boolean; } diff --git a/packages/core/src/useSearchField/useSearchField.ts b/packages/core/src/useSearchField/useSearchField.ts index 8a5f367d..b0e67347 100644 --- a/packages/core/src/useSearchField/useSearchField.ts +++ b/packages/core/src/useSearchField/useSearchField.ts @@ -7,7 +7,7 @@ import { Numberish, Reactivify, TextInputBaseAttributes, - TypedSchema, + StandardSchema, } from '../types'; import { createAccessibleErrorMessageProps, @@ -54,7 +54,7 @@ export interface SearchFieldProps { readonly?: boolean; disabled?: boolean; - schema?: TypedSchema; + schema?: StandardSchema; onSubmit?: (value: string) => void; diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index 662f5b62..d22607ed 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -1,6 +1,6 @@ import { computed, InjectionKey, provide, toValue } from 'vue'; import { useFormField } from '../useFormField'; -import { AriaLabelableProps, Arrayable, Orientation, Reactivify, TypedSchema } from '../types'; +import { AriaLabelableProps, Arrayable, Orientation, Reactivify, StandardSchema } from '../types'; import { createAccessibleErrorMessageProps, createDescribedByProps, @@ -30,7 +30,7 @@ export interface SelectProps { multiple?: boolean; orientation?: Orientation; - schema?: TypedSchema>; + schema?: StandardSchema>; } export interface SelectTriggerDomProps extends AriaLabelableProps { diff --git a/packages/core/src/useSlider/useSlider.ts b/packages/core/src/useSlider/useSlider.ts index e88fca0c..ef74aa64 100644 --- a/packages/core/src/useSlider/useSlider.ts +++ b/packages/core/src/useSlider/useSlider.ts @@ -1,6 +1,6 @@ import { InjectionKey, computed, onBeforeUnmount, provide, ref, toValue } from 'vue'; import { useLabel } from '../a11y/useLabel'; -import { AriaLabelableProps, Arrayable, Direction, Orientation, Reactivify, TypedSchema } from '../types'; +import { AriaLabelableProps, Arrayable, Direction, Orientation, Reactivify, StandardSchema } from '../types'; import { createAccessibleErrorMessageProps, ErrorableAttributes, @@ -32,7 +32,7 @@ export interface SliderProps { disabled?: boolean; readonly?: boolean; - schema?: TypedSchema; + schema?: StandardSchema; } export type Coordinate = { x: number; y: number }; diff --git a/packages/core/src/useSwitch/useSwitch.ts b/packages/core/src/useSwitch/useSwitch.ts index 370cab16..1eef2755 100644 --- a/packages/core/src/useSwitch/useSwitch.ts +++ b/packages/core/src/useSwitch/useSwitch.ts @@ -6,7 +6,7 @@ import { InputBaseAttributes, InputEvents, Reactivify, - TypedSchema, + StandardSchema, } from '../types'; import { createAccessibleErrorMessageProps, @@ -54,7 +54,7 @@ export type SwitchProps = { trueValue?: unknown; falseValue?: unknown; - schema?: TypedSchema; + schema?: StandardSchema; disableHtmlValidation?: boolean; }; diff --git a/packages/core/src/useTextField/useTextField.ts b/packages/core/src/useTextField/useTextField.ts index a55bf139..eb400c0c 100644 --- a/packages/core/src/useTextField/useTextField.ts +++ b/packages/core/src/useTextField/useTextField.ts @@ -20,7 +20,7 @@ import { useInputValidity } from '../validation/useInputValidity'; import { useLabel } from '../a11y/useLabel'; import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; -import { TypedSchema } from '../types'; +import { StandardSchema } from '../types'; import { exposeField } from '../utils/exposers'; export type TextInputDOMType = 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'; @@ -56,7 +56,7 @@ export interface TextFieldProps { readonly?: boolean; disabled?: boolean; - schema?: TypedSchema; + schema?: StandardSchema; disableHtmlValidation?: boolean; } diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts index f6822a4d..006d555c 100644 --- a/packages/core/src/utils/common.ts +++ b/packages/core/src/utils/common.ts @@ -1,7 +1,17 @@ import { computed, getCurrentScope, MaybeRefOrGetter, onScopeDispose, Ref, shallowRef, toValue, useId } from 'vue'; import { klona } from 'klona/full'; -import { AriaDescriptionProps, AriaErrorMessageProps, Arrayable, Maybe, NormalizedProps, WithId } from '../types'; +import { + AriaDescriptionProps, + AriaErrorMessageProps, + Arrayable, + IssueCollection, + Maybe, + NormalizedProps, + StandardIssue, + WithId, +} from '../types'; import { AsyncReturnType } from 'type-fest'; +import { getDotPath } from '@standard-schema/utils'; export function useUniqId(prefix?: string) { return prefix ? `${prefix}-${useId()}` : useId() || ''; @@ -391,3 +401,23 @@ export function tryOnScopeDispose(fn: () => void) { export function hasKeyCode(e: Event, code: string) { return (e as KeyboardEvent).code === code; } + +/** + * Aggregates issues by path. + */ +export function combineIssues(issues: StandardIssue[] | readonly StandardIssue[]): IssueCollection[] { + const issueMap: Record = {}; + for (const issue of issues) { + const path = issue.path ? (getDotPath(issue) ?? '') : ''; + if (!issueMap[path]) { + issueMap[path] = { + path, + messages: [], + }; + } + + issueMap[path].messages.push(issue.message); + } + + return Object.values(issueMap); +} diff --git a/packages/core/src/validation/useValidationProvider.ts b/packages/core/src/validation/useValidationProvider.ts index bc01ab00..eea17135 100644 --- a/packages/core/src/validation/useValidationProvider.ts +++ b/packages/core/src/validation/useValidationProvider.ts @@ -3,10 +3,11 @@ import { FormObject, FormValidationResult, GroupValidationResult, - TypedSchema, + IssueCollection, + StandardSchema, ValidationResult, } from '../types'; -import { batchAsync, cloneDeep, withLatestCall } from '../utils/common'; +import { batchAsync, cloneDeep, combineIssues, withLatestCall } from '../utils/common'; import { createEventDispatcher } from '../utils/events'; import { SCHEMA_BATCH_MS } from '../constants'; import { prefixPath, setInPath } from '../utils/path'; @@ -19,7 +20,7 @@ interface ValidationProviderOptions< TType extends AggregatorResult['type'], > { type: TType; - schema?: TypedSchema; + schema?: StandardSchema; getValues: () => TInput; getPath?: () => string; } @@ -57,11 +58,11 @@ export function useValidationProvider< }); } - const { errors: parseErrors, output } = await schema.parse(getValues()); - let errors = parseErrors; + const result = await schema['~validate']({ value: getValues() }); + let errors: IssueCollection[] = combineIssues(result.issues || []); const prefix = getPath?.(); if (prefix) { - errors = parseErrors.map(e => { + errors = errors.map(e => { return { messages: e.messages, path: prefixPath(prefix, e.path) || '', @@ -70,6 +71,7 @@ export function useValidationProvider< } const allErrors = [...errors, ...fieldErrors]; + const output = result.issues ? undefined : result.value; dispatchValidateDone(); diff --git a/packages/ecosystem/package.json b/packages/ecosystem/package.json new file mode 100644 index 00000000..fd1915df --- /dev/null +++ b/packages/ecosystem/package.json @@ -0,0 +1,9 @@ +{ + "name": "ecosystem", + "private": true, + "dependencies": { + "arktype": "2.0.0-rc.21", + "valibot": "1.0.0-beta.3", + "@formwerk/core": "workspace:*" + } +} diff --git a/packages/ecosystem/src/arktype/arktype.spec.ts b/packages/ecosystem/src/arktype/arktype.spec.ts new file mode 100644 index 00000000..1777a676 --- /dev/null +++ b/packages/ecosystem/src/arktype/arktype.spec.ts @@ -0,0 +1,48 @@ +import { type } from 'arktype'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { useForm } from '@formwerk/core'; +import { flush } from '@test-utils/index'; + +test('Arktype schemas are supported', async () => { + const handler = vi.fn(); + const schema = type({ + email: 'string.email', + password: 'string >= 8', + }); + + await render({ + setup() { + const { handleSubmit, getError } = useForm({ + schema, + initialValues: { + password: '1234567', + }, + }); + + // values.email; + + // values.password; + + return { + getError, + onSubmit: handleSubmit(v => { + handler(v.toJSON()); + }), + }; + }, + template: ` +
+ {{ getError('email') }} + {{ getError('password') }} + + +
+ `, + }); + + await fireEvent.click(screen.getByText('Submit')); + await flush(); + expect(screen.getByTestId('form-err-1').textContent).toBe('email must be a string (was missing)'); + expect(screen.getByTestId('form-err-2').textContent).toBe('password must be at least length 8 (was 7)'); + expect(handler).not.toHaveBeenCalled(); +}); diff --git a/packages/ecosystem/src/valibot/valibot.spec.ts b/packages/ecosystem/src/valibot/valibot.spec.ts new file mode 100644 index 00000000..549c9d6a --- /dev/null +++ b/packages/ecosystem/src/valibot/valibot.spec.ts @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from '@testing-library/vue'; +import * as v from 'valibot'; +import { useForm } from '@formwerk/core'; +import { flush } from '@test-utils/index'; + +test('valibot schemas are supported', async () => { + const handler = vi.fn(); + const schema = v.object({ + email: v.optional(v.pipe(v.string(), v.email())), + password: v.pipe(v.string('not a string'), v.minLength(8)), + }); + + await render({ + setup() { + const { handleSubmit, getError, values } = useForm({ + schema, + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + values.email; + + // values.password.charAt; + + return { + getError, + onSubmit: handleSubmit(v => { + handler(v.toJSON()); + }), + }; + }, + template: ` +
+ {{ getError('email') }} + {{ getError('password') }} + + +
+ `, + }); + + await fireEvent.click(screen.getByText('Submit')); + await flush(); + expect(screen.getByTestId('form-err-1').textContent).toBe(''); + expect(screen.getByTestId('form-err-2').textContent).toBe('not a string'); + expect(handler).not.toHaveBeenCalled(); +}); + +test('collects multiple errors per field', async () => { + const handler = vi.fn(); + const schema = v.object({ + test: v.pipe(v.string(), v.email(), v.minLength(8)), + }); + + await render({ + setup() { + const { getErrors, validate } = useForm({ + schema, + initialValues: { + test: '123', + }, + }); + + return { + onSubmit: async () => { + await validate(); + + handler(getErrors()); + }, + }; + }, + template: ` +
+ +
+ `, + }); + + await fireEvent.click(screen.getByText('Submit')); + await flush(); + expect(handler).toHaveBeenCalledWith([ + { + path: 'test', + messages: ['Invalid email: Received "123"', 'Invalid length: Expected >=8 but received 3'], + }, + ]); +}); diff --git a/packages/playground/package.json b/packages/playground/package.json index 46eb2bb6..ca4f03aa 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -9,8 +9,6 @@ }, "dependencies": { "@formwerk/core": "workspace:*", - "@formwerk/schema-yup": "workspace:*", - "@formwerk/schema-zod": "workspace:*", "tailwindcss": "^3.4.14", "vue": "^3.5.12", "vue-i18n": "^10.0.4", diff --git a/packages/schema-yup/CHANGELOG.md b/packages/schema-yup/CHANGELOG.md deleted file mode 100644 index ec342f96..00000000 --- a/packages/schema-yup/CHANGELOG.md +++ /dev/null @@ -1,227 +0,0 @@ -# @formwerk/schema-yup - -## 0.1.24 - -### Patch Changes - -- Updated dependencies [452b2dc] -- Updated dependencies [3c9be6f] -- Updated dependencies [6e8f396] -- Updated dependencies [194cb14] -- Updated dependencies [97a1cb9] -- Updated dependencies [6c5cb5f] -- Updated dependencies [20cc8a9] -- Updated dependencies [80364a3] - - @formwerk/core@0.1.24 - -## 0.1.23 - -### Patch Changes - -- Updated dependencies [0f7f05e] -- Updated dependencies [e4c8a99] -- Updated dependencies [0da6b62] - - @formwerk/core@0.1.23 - -## 0.1.22 - -### Patch Changes - -- 07584bf: fix: include cjs and mjs files in dist while publishing -- Updated dependencies [07584bf] - - @formwerk/core@0.1.22 - -## 0.1.21 - -### Patch Changes - -- ba8771e: chore: rename package outputs -- Updated dependencies [1f13a5f] -- Updated dependencies [ba8771e] - - @formwerk/core@0.1.21 - -## 0.1.20 - -### Patch Changes - -- 94fe184: fix: add proper exports in package.json for all packages -- Updated dependencies [9c9b235] -- Updated dependencies [e822e3f] -- Updated dependencies [159d86a] -- Updated dependencies [94fe184] -- Updated dependencies [3878bd2] - - @formwerk/core@0.1.20 - -## 0.1.19 - -### Patch Changes - -- Updated dependencies [00f920b] -- Updated dependencies [03e74ec] - - @formwerk/core@0.1.19 - -## 0.1.18 - -### Patch Changes - -- Updated dependencies [f9d9416] -- Updated dependencies [2a8e808] -- Updated dependencies [3759cc1] -- Updated dependencies [05ecda4] -- Updated dependencies [8ce260b] - - @formwerk/core@0.1.18 - -## 0.1.17 - -### Patch Changes - -- Updated dependencies [4f70409] -- Updated dependencies [591f7c4] -- Updated dependencies [b01a6ce] - - @formwerk/core@0.1.17 - -## 0.1.16 - -### Patch Changes - -- Updated dependencies [23c2f6d] - - @formwerk/core@0.1.16 - -## 0.1.15 - -### Patch Changes - -- Updated dependencies [68b4d97] - - @formwerk/core@0.1.15 - -## 0.1.14 - -### Patch Changes - -- Updated dependencies [0dd3d8e] -- Updated dependencies [2fb6f90] - - @formwerk/core@0.1.14 - -## 0.1.13 - -### Patch Changes - -- Updated dependencies [61a0ec0] -- Updated dependencies [6523ba9] -- Updated dependencies [4205a69] -- Updated dependencies [90b0102] -- Updated dependencies [419eeff] -- Updated dependencies [ba4e329] -- Updated dependencies [9d612aa] - - @formwerk/core@0.1.13 - -## 0.1.12 - -### Patch Changes - -- Updated dependencies [0274c1f] -- Updated dependencies [59de4fd] -- Updated dependencies [8a4aae1] -- Updated dependencies [7a7c226] - - @formwerk/core@0.1.12 - -## 0.1.11 - -### Patch Changes - -- Updated dependencies [c5ce5a1] -- Updated dependencies [68775ed] -- Updated dependencies [4cdde34] -- Updated dependencies [4d2efc6] -- Updated dependencies [16faf37] -- Updated dependencies [14425a9] - - @formwerk/core@0.1.11 - -## 0.1.10 - -### Patch Changes - -- Updated dependencies [90baccc] - - @formwerk/core@0.1.10 - -## 0.1.9 - -### Patch Changes - -- Updated dependencies [dc9cc4e] - - @formwerk/core@0.1.9 - -## 0.1.8 - -### Patch Changes - -- Updated dependencies [d3f51a7] -- Updated dependencies [b545643] - - @formwerk/core@0.1.8 - -## 0.1.7 - -### Patch Changes - -- Updated dependencies [33a630d] -- Updated dependencies [44a0bcd] - - @formwerk/core@0.1.7 - -## 0.1.6 - -### Patch Changes - -- Updated dependencies [7dc0e69] -- Updated dependencies [1f9f28f] -- Updated dependencies [2017403] - - @formwerk/core@0.1.6 - -## 0.1.5 - -### Patch Changes - -- 0ce56e1: chore: upgrade dependencies and reduce vue minimum version -- Updated dependencies [0ce56e1] -- Updated dependencies [f89c2c5] - - @formwerk/core@0.1.5 - -## 0.1.4 - -### Patch Changes - -- Updated dependencies [2ebb2b7] - - @formwerk/core@0.1.4 - -## 0.1.3 - -### Patch Changes - -- Updated dependencies [be87654] -- Updated dependencies [ff28d3d] -- Updated dependencies [c680f7f] - - @formwerk/core@0.1.3 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [056fda1] - - @formwerk/core@0.1.2 - -## 0.1.1 - -### Patch Changes - -- Updated dependencies [0d551e5] - - @formwerk/core@0.1.1 - -## 0.1.0 - -### Minor Changes - -- 4634ea5: Initial internal test release - -### Patch Changes - -- Updated dependencies [4634ea5] - - @formwerk/core@0.1.0 diff --git a/packages/schema-yup/README.md b/packages/schema-yup/README.md deleted file mode 100644 index 0214e371..00000000 --- a/packages/schema-yup/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Yup - -This is the typed schema implementation for the `yup` provider. diff --git a/packages/schema-yup/package.json b/packages/schema-yup/package.json deleted file mode 100644 index 05e7074b..00000000 --- a/packages/schema-yup/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@formwerk/schema-yup", - "version": "0.1.24", - "description": "", - "sideEffects": false, - "module": "dist/schema-yup.mjs", - "unpkg": "dist/schema-yup.iife.js", - "jsdelivr": "dist/schema-yup.iife.js", - "main": "dist/schema-yup.mjs", - "types": "dist/schema-yup.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/schema-yup.mjs", - "require": "./dist/schema-yup.cjs", - "types": "./dist/schema-yup.d.ts" - } - }, - "repository": { - "url": "https://github.com/formwerkjs/formwerk.git", - "type": "git", - "directory": "packages/schema-yup" - }, - "keywords": [ - "VueJS", - "Vue", - "validation", - "validator", - "inputs", - "form" - ], - "files": [ - "dist/*.js", - "dist/*.mjs", - "dist/*.cjs", - "dist/*.d.ts" - ], - "dependencies": { - "@formwerk/core": "workspace:*", - "type-fest": "^4.26.1", - "yup": "^1.3.2" - }, - "author": "", - "license": "MIT" -} diff --git a/packages/schema-yup/src/index.spec.ts b/packages/schema-yup/src/index.spec.ts deleted file mode 100644 index f48ac39c..00000000 --- a/packages/schema-yup/src/index.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { type Component } from 'vue'; -import { fireEvent, render, screen } from '@testing-library/vue'; -import { useForm, useTextField } from '@formwerk/core'; -import { defineSchema } from '.'; -import * as y from 'yup'; -import { flush } from '@test-utils/index'; - -const requiredMessage = (field: string) => `${field} is a required field`; - -describe('schema-yup', () => { - function createInputComponent(): Component { - return { - inheritAttrs: false, - setup: (_, { attrs }) => { - const name = (attrs.name || 'test') as string; - const { errorMessage, inputProps } = useTextField({ name, label: name }); - - return { errorMessage: errorMessage, inputProps, name }; - }, - template: ` - - {{ errorMessage }} - `, - }; - } - - test('validates initially with yup schema', async () => { - await render({ - components: { Child: createInputComponent() }, - setup() { - const { getError, isValid } = useForm({ - schema: defineSchema( - y.object({ - test: y.string().required(), - }), - ), - }); - - return { getError, isValid }; - }, - template: ` -
- - - {{ getError('test') }} - {{ isValid }} - - `, - }); - - await flush(); - expect(screen.getByTestId('form-valid').textContent).toBe('false'); - expect(screen.getByTestId('err').textContent).toBe(requiredMessage('test')); - expect(screen.getByTestId('form-err').textContent).toBe(requiredMessage('test')); - }); - - test('validates nested paths', async () => { - await render({ - components: { Child: createInputComponent() }, - setup() { - const { getError, isValid } = useForm({ - schema: defineSchema( - y.object({ - some: y.object({ - deep: y.object({ - path: y.string().required(), - }), - array: y.array().of( - y.object({ - path: y.string().required(), - }), - ), - }), - }), - ), - }); - - return { getError, isValid }; - }, - template: ` -
- - - - {{ isValid }} - {{ getError('some.deep.path') }} - {{ getError('some.array.0.path') }} - - `, - }); - - await flush(); - expect(screen.getByTestId('is-valid').textContent).toBe('false'); - expect(screen.getByTestId('e1').textContent).toBe(requiredMessage('some.deep.path')); - expect(screen.getByTestId('e2').textContent).toBe(requiredMessage('some.array[0].path')); - - await fireEvent.update(screen.getByTestId('some.deep.path'), 'test'); - await fireEvent.update(screen.getByTestId('some.array.0.path'), 'test'); - await fireEvent.blur(screen.getByTestId('some.deep.path')); - await fireEvent.blur(screen.getByTestId('some.array.0.path')); - await flush(); - expect(screen.getByTestId('is-valid').textContent).toBe('true'); - expect(screen.getByTestId('e1').textContent).toBe(''); - expect(screen.getByTestId('e2').textContent).toBe(''); - }); - - test('prevents submission if the form is not valid', async () => { - const handler = vi.fn(); - - await render({ - components: { Child: createInputComponent() }, - setup() { - const { handleSubmit } = useForm({ - schema: defineSchema( - y.object({ - test: y.string().required(), - }), - ), - }); - - return { onSubmit: handleSubmit(v => handler(v.toJSON())) }; - }, - template: ` -
- - - - - `, - }); - - await fireEvent.click(screen.getByText('Submit')); - await flush(); - expect(handler).not.toHaveBeenCalled(); - await fireEvent.update(screen.getByTestId('test'), 'test'); - await fireEvent.click(screen.getByText('Submit')); - await flush(); - expect(handler).toHaveBeenCalledOnce(); - }); - - test('supports transformations', async () => { - const handler = vi.fn(); - - await render({ - components: { Child: createInputComponent() }, - setup() { - const { handleSubmit, getError } = useForm({ - schema: defineSchema( - y.object({ - test: y - .string() - .required() - .transform(value => (value ? `epic-${value}` : value)), - age: y - .number() - .required() - .transform(value => Number(value)), - }), - ), - }); - - return { getError, onSubmit: handleSubmit(v => handler(v.toJSON())) }; - }, - template: ` -
- - - - - - `, - }); - - await flush(); - await fireEvent.update(screen.getByTestId('test'), 'test'); - await fireEvent.update(screen.getByTestId('age'), '11'); - await fireEvent.click(screen.getByText('Submit')); - await flush(); - expect(handler).toHaveBeenCalledOnce(); - expect(handler).toHaveBeenLastCalledWith({ test: 'epic-test', age: 11 }); - }); - - test('supports defaults', async () => { - const handler = vi.fn(); - - await render({ - components: { Child: createInputComponent() }, - setup() { - const { handleSubmit, getError } = useForm({ - schema: defineSchema( - y.object({ - test: y.string().required().default('default-test'), - age: y.number().required().default(22), - }), - ), - }); - - return { getError, onSubmit: handleSubmit(v => handler(v.toJSON())) }; - }, - template: ` -
- - - - - - `, - }); - - await flush(); - await expect(screen.getByDisplayValue('default-test')).toBeDefined(); - await expect(screen.getByDisplayValue('22')).toBeDefined(); - }); -}); diff --git a/packages/schema-yup/src/index.ts b/packages/schema-yup/src/index.ts deleted file mode 100644 index 2652958b..00000000 --- a/packages/schema-yup/src/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { InferType, Schema, ValidateOptions, ValidationError } from 'yup'; -import type { PartialDeep } from 'type-fest'; -import { TypedSchema, TypedSchemaError, normalizePath } from '@formwerk/core'; -import { isObject, merge } from '../../shared/src'; - -export function defineSchema, TInput = PartialDeep>( - yupSchema: TSchema, - opts: ValidateOptions = { abortEarly: false }, -): TypedSchema { - const schema: TypedSchema = { - async parse(values) { - try { - // we spread the options because yup mutates the opts object passed - const output = await yupSchema.validate(values, { ...opts }); - - return { - output, - errors: [], - }; - } catch (err) { - const error = err as ValidationError; - // Yup errors have a name prop one them. - // https://github.com/jquense/yup#validationerrorerrors-string--arraystring-value-any-path-string - if (error.name !== 'ValidationError') { - throw err; - } - - if (!error.inner?.length && error.errors.length) { - return { errors: [{ path: normalizePath(error.path as string), messages: error.errors }] }; - } - - const errors: Record = error.inner.reduce( - (acc, curr) => { - const path = normalizePath(curr.path || ''); - if (!acc[path]) { - acc[path] = { messages: [], path }; - } - - acc[path].messages.push(...curr.errors); - - return acc; - }, - {} as Record, - ); - - // list of aggregated errors - return { errors: Object.values(errors) }; - } - }, - defaults(values) { - try { - return yupSchema.cast(values); - } catch { - const defaults = yupSchema.getDefault(); - if (isObject(defaults) && isObject(values)) { - return merge(defaults, values); - } - - return values; - } - }, - }; - - return schema; -} diff --git a/packages/schema-zod/CHANGELOG.md b/packages/schema-zod/CHANGELOG.md deleted file mode 100644 index a447f3b8..00000000 --- a/packages/schema-zod/CHANGELOG.md +++ /dev/null @@ -1,227 +0,0 @@ -# @formwerk/schema-zod - -## 0.1.24 - -### Patch Changes - -- Updated dependencies [452b2dc] -- Updated dependencies [3c9be6f] -- Updated dependencies [6e8f396] -- Updated dependencies [194cb14] -- Updated dependencies [97a1cb9] -- Updated dependencies [6c5cb5f] -- Updated dependencies [20cc8a9] -- Updated dependencies [80364a3] - - @formwerk/core@0.1.24 - -## 0.1.23 - -### Patch Changes - -- Updated dependencies [0f7f05e] -- Updated dependencies [e4c8a99] -- Updated dependencies [0da6b62] - - @formwerk/core@0.1.23 - -## 0.1.22 - -### Patch Changes - -- 07584bf: fix: include cjs and mjs files in dist while publishing -- Updated dependencies [07584bf] - - @formwerk/core@0.1.22 - -## 0.1.21 - -### Patch Changes - -- ba8771e: chore: rename package outputs -- Updated dependencies [1f13a5f] -- Updated dependencies [ba8771e] - - @formwerk/core@0.1.21 - -## 0.1.20 - -### Patch Changes - -- 94fe184: fix: add proper exports in package.json for all packages -- Updated dependencies [9c9b235] -- Updated dependencies [e822e3f] -- Updated dependencies [159d86a] -- Updated dependencies [94fe184] -- Updated dependencies [3878bd2] - - @formwerk/core@0.1.20 - -## 0.1.19 - -### Patch Changes - -- Updated dependencies [00f920b] -- Updated dependencies [03e74ec] - - @formwerk/core@0.1.19 - -## 0.1.18 - -### Patch Changes - -- Updated dependencies [f9d9416] -- Updated dependencies [2a8e808] -- Updated dependencies [3759cc1] -- Updated dependencies [05ecda4] -- Updated dependencies [8ce260b] - - @formwerk/core@0.1.18 - -## 0.1.17 - -### Patch Changes - -- Updated dependencies [4f70409] -- Updated dependencies [591f7c4] -- Updated dependencies [b01a6ce] - - @formwerk/core@0.1.17 - -## 0.1.16 - -### Patch Changes - -- Updated dependencies [23c2f6d] - - @formwerk/core@0.1.16 - -## 0.1.15 - -### Patch Changes - -- Updated dependencies [68b4d97] - - @formwerk/core@0.1.15 - -## 0.1.14 - -### Patch Changes - -- Updated dependencies [0dd3d8e] -- Updated dependencies [2fb6f90] - - @formwerk/core@0.1.14 - -## 0.1.13 - -### Patch Changes - -- Updated dependencies [61a0ec0] -- Updated dependencies [6523ba9] -- Updated dependencies [4205a69] -- Updated dependencies [90b0102] -- Updated dependencies [419eeff] -- Updated dependencies [ba4e329] -- Updated dependencies [9d612aa] - - @formwerk/core@0.1.13 - -## 0.1.12 - -### Patch Changes - -- Updated dependencies [0274c1f] -- Updated dependencies [59de4fd] -- Updated dependencies [8a4aae1] -- Updated dependencies [7a7c226] - - @formwerk/core@0.1.12 - -## 0.1.11 - -### Patch Changes - -- Updated dependencies [c5ce5a1] -- Updated dependencies [68775ed] -- Updated dependencies [4cdde34] -- Updated dependencies [4d2efc6] -- Updated dependencies [16faf37] -- Updated dependencies [14425a9] - - @formwerk/core@0.1.11 - -## 0.1.10 - -### Patch Changes - -- Updated dependencies [90baccc] - - @formwerk/core@0.1.10 - -## 0.1.9 - -### Patch Changes - -- Updated dependencies [dc9cc4e] - - @formwerk/core@0.1.9 - -## 0.1.8 - -### Patch Changes - -- Updated dependencies [d3f51a7] -- Updated dependencies [b545643] - - @formwerk/core@0.1.8 - -## 0.1.7 - -### Patch Changes - -- Updated dependencies [33a630d] -- Updated dependencies [44a0bcd] - - @formwerk/core@0.1.7 - -## 0.1.6 - -### Patch Changes - -- Updated dependencies [7dc0e69] -- Updated dependencies [1f9f28f] -- Updated dependencies [2017403] - - @formwerk/core@0.1.6 - -## 0.1.5 - -### Patch Changes - -- 0ce56e1: chore: upgrade dependencies and reduce vue minimum version -- Updated dependencies [0ce56e1] -- Updated dependencies [f89c2c5] - - @formwerk/core@0.1.5 - -## 0.1.4 - -### Patch Changes - -- Updated dependencies [2ebb2b7] - - @formwerk/core@0.1.4 - -## 0.1.3 - -### Patch Changes - -- Updated dependencies [be87654] -- Updated dependencies [ff28d3d] -- Updated dependencies [c680f7f] - - @formwerk/core@0.1.3 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [056fda1] - - @formwerk/core@0.1.2 - -## 0.1.1 - -### Patch Changes - -- Updated dependencies [0d551e5] - - @formwerk/core@0.1.1 - -## 0.1.0 - -### Minor Changes - -- 4634ea5: Initial internal test release - -### Patch Changes - -- Updated dependencies [4634ea5] - - @formwerk/core@0.1.0 diff --git a/packages/schema-zod/README.md b/packages/schema-zod/README.md deleted file mode 100644 index 017fe154..00000000 --- a/packages/schema-zod/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Zod - -This is the typed schema implementation for the `zod` provider. diff --git a/packages/schema-zod/package.json b/packages/schema-zod/package.json deleted file mode 100644 index 1ae09478..00000000 --- a/packages/schema-zod/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@formwerk/schema-zod", - "version": "0.1.24", - "description": "", - "sideEffects": false, - "module": "dist/schema-zod.mjs", - "unpkg": "dist/schema-zod.iife.js", - "jsdelivr": "dist/schema-zod.iife.js", - "main": "dist/schema-zod.mjs", - "types": "dist/schema-zod.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/schema-zod.mjs", - "require": "./dist/schema-zod.cjs", - "types": "./dist/schema-zod.d.ts" - } - }, - "repository": { - "url": "https://github.com/formwerkjs/formwerk.git", - "type": "git", - "directory": "packages/schema-yup" - }, - "keywords": [ - "VueJS", - "Vue", - "validation", - "validator", - "inputs", - "form", - "zod" - ], - "files": [ - "dist/*.js", - "dist/*.mjs", - "dist/*.cjs", - "dist/*.d.ts" - ], - "dependencies": { - "@formwerk/core": "workspace:*", - "type-fest": "^4.26.1", - "zod": "^3.23.8" - }, - "author": "", - "license": "MIT" -} diff --git a/packages/schema-zod/src/index.spec.ts b/packages/schema-zod/src/index.spec.ts deleted file mode 100644 index b8a84204..00000000 --- a/packages/schema-zod/src/index.spec.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { type Component } from 'vue'; -import { fireEvent, render, screen } from '@testing-library/vue'; -import { useForm, useTextField } from '@formwerk/core'; -import { defineSchema } from '.'; -import { z } from 'zod'; -import { flush } from '@test-utils/index'; - -describe('schema-zod', () => { - function createInputComponent(): Component { - return { - inheritAttrs: false, - setup: (_, { attrs }) => { - const name = (attrs.name || 'test') as string; - const { errorMessage, inputProps } = useTextField({ name, label: name }); - - return { errorMessage: errorMessage, inputProps, name }; - }, - template: ` - - {{ errorMessage }} - `, - }; - } - - test('initial validation', async () => { - await render({ - components: { Child: createInputComponent() }, - setup() { - const { getError, isValid } = useForm({ - schema: defineSchema( - z.object({ - test: z.string().min(1, 'Required'), - }), - ), - }); - - return { getError, isValid }; - }, - template: ` -
- - - {{ getError('test') }} - {{ isValid }} - - `, - }); - - await flush(); - expect(screen.getByTestId('form-valid').textContent).toBe('false'); - expect(screen.getByTestId('err').textContent).toBe('Required'); - expect(screen.getByTestId('form-err').textContent).toBe('Required'); - }); - - test('prevents submission if the form is not valid', async () => { - const handler = vi.fn(); - - await render({ - components: { Child: createInputComponent() }, - setup() { - const { handleSubmit } = useForm({ - schema: defineSchema( - z.object({ - test: z.string().min(1, 'Required'), - }), - ), - }); - - return { onSubmit: handleSubmit(v => handler(v.toJSON())) }; - }, - template: ` -
- - - - - `, - }); - - await fireEvent.click(screen.getByText('Submit')); - await flush(); - expect(handler).not.toHaveBeenCalled(); - await fireEvent.update(screen.getByTestId('test'), 'test'); - await fireEvent.click(screen.getByText('Submit')); - await flush(); - - expect(handler).toHaveBeenCalledOnce(); - }); - - test('supports transformations and preprocess', async () => { - const handler = vi.fn(); - - await render({ - components: { Child: createInputComponent() }, - setup() { - const { handleSubmit, getError } = useForm({ - schema: defineSchema( - z.object({ - test: z.string().transform(value => (value ? `epic-${value}` : value)), - age: z.preprocess(arg => Number(arg), z.number()), - }), - ), - }); - - return { getError, onSubmit: handleSubmit(v => handler(v.toJSON())) }; - }, - template: ` -
- - - - - - `, - }); - - await flush(); - await fireEvent.update(screen.getByTestId('test'), 'test'); - await fireEvent.update(screen.getByTestId('age'), '11'); - await fireEvent.click(screen.getByText('Submit')); - await flush(); - expect(handler).toHaveBeenCalledOnce(); - expect(handler).toHaveBeenLastCalledWith({ test: 'epic-test', age: 11 }); - }); - - test('supports defaults', async () => { - const handler = vi.fn(); - - await render({ - components: { Child: createInputComponent() }, - setup() { - const { handleSubmit, getError } = useForm({ - schema: defineSchema( - z.object({ - test: z.string().min(1, 'Required').default('default-test'), - age: z.number().min(1, 'Required').default(22), - }), - ), - }); - - return { getError, onSubmit: handleSubmit(v => handler(v.toJSON())) }; - }, - template: ` -
- - - - - - `, - }); - - await flush(); - await expect(screen.getByDisplayValue('default-test')).toBeDefined(); - await expect(screen.getByDisplayValue('22')).toBeDefined(); - }); - - test('validates nested paths', async () => { - await render({ - components: { Child: createInputComponent() }, - setup() { - const { getError, isValid, values } = useForm({ - schema: defineSchema( - z.object({ - some: z.object({ - deep: z.object({ - path: z.string().min(1, 'Required'), - }), - array: z.array( - z.object({ - path: z.string().min(1, 'Required'), - }), - ), - }), - }), - ), - }); - - return { getError, isValid, values }; - }, - template: ` -
- - - {{values}} - - {{ isValid }} - {{ getError('some.deep.path') }} - {{ getError('some.array.0.path') }} - - `, - }); - - await flush(); - expect(screen.getByTestId('is-valid').textContent).toBe('false'); - expect(screen.getByTestId('e1').textContent).toBe('Required'); - expect(screen.getByTestId('e2').textContent).toBe('Required'); - - await fireEvent.update(screen.getByTestId('some.deep.path'), 'test'); - await fireEvent.update(screen.getByTestId('some.array.0.path'), 'test'); - await fireEvent.blur(screen.getByTestId('some.deep.path')); - await fireEvent.blur(screen.getByTestId('some.array.0.path')); - await flush(); - expect(screen.getByTestId('is-valid').textContent).toBe('true'); - expect(screen.getByTestId('e1').textContent).toBe(''); - expect(screen.getByTestId('e2').textContent).toBe(''); - }); - - test('handles zod union errors', async () => { - await render({ - components: { Child: createInputComponent() }, - setup() { - const schema = z.object({ - email: z.string().email({ message: 'valid email' }).min(1, 'Email is required'), - name: z.string().min(1, 'Name is required'), - }); - - const schemaBothUndefined = z.object({ - email: z.undefined(), - name: z.undefined(), - }); - - const bothOrNeither = schema.or(schemaBothUndefined); - - const { getError } = useForm({ - schema: defineSchema(bothOrNeither), - }); - - return { - schema, - getError, - }; - }, - template: ` -
- - {{ getError('email') }} - - - {{ getError('name') }} -
- `, - }); - - const emailField = screen.getByTestId('email'); - const nameField = screen.getByTestId('name'); - const emailError = screen.getByTestId('emailErr'); - const nameError = screen.getByTestId('nameErr'); - - await flush(); - - await fireEvent.update(nameField, '4'); - await fireEvent.blur(nameField); - await flush(); - expect(nameError.textContent).toBe('Expected undefined, received string'); - await fireEvent.update(emailField, 'test@gmail.com'); - await fireEvent.blur(nameField); - await flush(); - - expect(emailError.textContent).toBe(''); - expect(nameError.textContent).toBe(''); - }); -}); diff --git a/packages/schema-zod/src/index.ts b/packages/schema-zod/src/index.ts deleted file mode 100644 index 81502c32..00000000 --- a/packages/schema-zod/src/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ZodObject, input, output, ZodDefault, ZodSchema, ParseParams, ZodIssue } from 'zod'; -import { PartialDeep } from 'type-fest'; -import type { TypedSchema, TypedSchemaError } from '@formwerk/core'; -import { isObject, merge } from '../../shared/src'; - -/** - * Transforms a Zod object schema to Yup's schema - */ -export function defineSchema< - TSchema extends ZodSchema, - TOutput = output, - TInput = PartialDeep>, ->(zodSchema: TSchema, opts?: Partial): TypedSchema { - const schema: TypedSchema = { - async parse(value) { - const result = await zodSchema.safeParseAsync(value, opts); - if (result.success) { - return { - output: result.data, - errors: [], - }; - } - - const errors: Record = {}; - processIssues(result.error.issues, errors); - - return { - errors: Object.values(errors), - }; - }, - defaults(values) { - try { - return zodSchema.parse(values); - } catch { - // Zod does not support "casting" or not validating a value, so next best thing is getting the defaults and merging them with the provided values. - const defaults = getDefaults(zodSchema); - if (isObject(defaults) && isObject(values)) { - return merge(defaults, values); - } - - return values; - } - }, - }; - - return schema; -} - -function processIssues(issues: ZodIssue[], errors: Record): void { - issues.forEach(issue => { - const path = issue.path.join('.'); - if (issue.code === 'invalid_union') { - processIssues( - issue.unionErrors.flatMap(ue => ue.issues), - errors, - ); - - if (!path) { - return; - } - } - - if (!errors[path]) { - errors[path] = { messages: [], path }; - } - - errors[path].messages.push(issue.message); - }); -} - -// Zod does not support extracting default values so the next best thing is manually extracting them. -// https://github.com/colinhacks/zod/issues/1944#issuecomment-1406566175 -function getDefaults(schema: Schema): unknown { - if (!(schema instanceof ZodObject)) { - return undefined; - } - - return Object.fromEntries( - Object.entries(schema.shape).map(([key, value]) => { - if (value instanceof ZodDefault) { - return [key, value._def.defaultValue()]; - } - - if (value instanceof ZodObject) { - return [key, getDefaults(value)]; - } - - return [key, undefined]; - }), - ); -} diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index dea6ef57..cf30f5a6 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,4 +1,7 @@ { "name": "@test-utils", - "private": true + "private": true, + "devDependencies": { + "@standard-schema/spec": "1.0.0-beta.1" + } } diff --git a/packages/test-utils/src/common.ts b/packages/test-utils/src/common.ts new file mode 100644 index 00000000..c03fa298 --- /dev/null +++ b/packages/test-utils/src/common.ts @@ -0,0 +1,11 @@ +import { v1 } from '@standard-schema/spec'; + +export function defineStandardSchema(validate: v1.StandardValidate) { + const schema: v1.StandardSchema = { + '~standard': 1, + '~vendor': 'custom', + '~validate': validate, + }; + + return schema; +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 515039c7..76581a90 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,2 +1,3 @@ export * from './renderSetup'; export * from './flush'; +export * from './common'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe88d1d8..497d97b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,12 @@ importers: packages/core: dependencies: + '@standard-schema/spec': + specifier: 1.0.0-beta.1 + version: 1.0.0-beta.1 + '@standard-schema/utils': + specifier: ^0.1.1 + version: 0.1.1 klona: specifier: ^2.0.6 version: 2.0.6 @@ -153,17 +159,23 @@ importers: specifier: '>=3.5.0' version: 3.5.12(typescript@5.6.3) - packages/playground: + packages/ecosystem: dependencies: '@formwerk/core': specifier: workspace:* version: link:../core - '@formwerk/schema-yup': - specifier: workspace:* - version: link:../schema-yup - '@formwerk/schema-zod': + arktype: + specifier: 2.0.0-rc.21 + version: 2.0.0-rc.21 + valibot: + specifier: 1.0.0-beta.3 + version: 1.0.0-beta.3(typescript@5.6.3) + + packages/playground: + dependencies: + '@formwerk/core': specifier: workspace:* - version: link:../schema-zod + version: link:../core tailwindcss: specifier: ^3.4.14 version: 3.4.14 @@ -193,31 +205,11 @@ importers: specifier: ^2.1.6 version: 2.1.6(typescript@5.6.3) - packages/schema-yup: - dependencies: - '@formwerk/core': - specifier: workspace:* - version: link:../core - type-fest: - specifier: ^4.26.1 - version: 4.26.1 - yup: - specifier: ^1.3.2 - version: 1.4.0 - - packages/schema-zod: - dependencies: - '@formwerk/core': - specifier: workspace:* - version: link:../core - type-fest: - specifier: ^4.26.1 - version: 4.26.1 - zod: - specifier: ^3.23.8 - version: 3.23.8 - - packages/test-utils: {} + packages/test-utils: + devDependencies: + '@standard-schema/spec': + specifier: 1.0.0-beta.1 + version: 1.0.0-beta.1 packages: @@ -229,6 +221,12 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@ark/schema@0.21.0': + resolution: {integrity: sha512-lYcE2S865F8XNId3Id7SGuVletZVqXs0jtcImX8fPGeSIxzkP43zinrkoeU3SMJB/KJUfhpNxQqBdveCyMOjYQ==} + + '@ark/util@0.21.0': + resolution: {integrity: sha512-VeE8OJncWZ1+oKAXp+rNY7IE2AibN/GRWK/ImCqKMC6Z0IHH9LDS/5dtR4C3RvNJw1O9Sur23S93a5HLFkaU1A==} + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -1015,6 +1013,12 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.0.0-beta.1': + resolution: {integrity: sha512-XFHxCgvFiNrofjsZ1SFLKjLSo6kM9WITBU6gPnkKtrQ6fSuPWhZ/7gLTWmMcMprFgN4FfU1Wcsr5+jNkRaksCQ==} + + '@standard-schema/utils@0.1.1': + resolution: {integrity: sha512-Uc/EWqUoxElTp50GChAK4l/QTn82RiSMsJB9dtUiIo54HHCpxByOxBGAOh73J1AGxMjk1ALvlDCyueOdvaxxPw==} + '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -1325,6 +1329,9 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arktype@2.0.0-rc.21: + resolution: {integrity: sha512-XbgxgFsONklbE1alpTQtcV/Foew39zphCXgPzx4g1o+hNMlLgPeNqtcgjC/dBwhyYqEHPlwNIk1vrKO0JFs7yg==} + array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} @@ -3627,6 +3634,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.0.0-beta.3: + resolution: {integrity: sha512-PRknKVj2249cF8Pxqil1dahkVHgyaPDq++Y8sA96R5lx4nYnVazs11kefOsRVD3PSygYJG5q2MEdEm7kuSWa+g==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3887,6 +3902,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@ark/schema@0.21.0': + dependencies: + '@ark/util': 0.21.0 + + '@ark/util@0.21.0': {} + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -4657,6 +4678,10 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.0.0-beta.1': {} + + '@standard-schema/utils@0.1.1': {} + '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.24.7 @@ -5053,6 +5078,11 @@ snapshots: dependencies: dequal: 2.0.3 + arktype@2.0.0-rc.21: + dependencies: + '@ark/schema': 0.21.0 + '@ark/util': 0.21.0 + array-buffer-byte-length@1.0.0: dependencies: call-bind: 1.0.5 @@ -7521,6 +7551,10 @@ snapshots: util-deprecate@1.0.2: {} + valibot@1.0.0-beta.3(typescript@5.6.3): + optionalDependencies: + typescript: 5.6.3 + validate-npm-package-name@5.0.1: {} vite-node@2.1.3(@types/node@22.7.5)(terser@5.34.1): diff --git a/scripts/build.ts b/scripts/build.ts index db1e2168..8620eb0d 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -41,7 +41,7 @@ function logPkgSize(pkg: string) { async function build(pkg) { consola.start(`📦 Generating bundle for @formwerk/${pkg}`); const pkgout = path.join(__dirname, `../packages/${pkg}/dist`); - await fs.emptyDir(pkgout); + // await fs.emptyDir(pkgout); for (const format of ['esm', 'iife', 'cjs'] as ModuleFormat[]) { const { input, output, bundleName } = await createConfig(pkg, format); const bundle = await rollup(input); diff --git a/scripts/config.ts b/scripts/config.ts index 263760dc..b1a2ca2d 100644 --- a/scripts/config.ts +++ b/scripts/config.ts @@ -12,14 +12,10 @@ const __dirname = dirname(__filename); const formatNameMap = { core: 'Formwerk', - 'schema-yup': 'FormwerkYup', - 'schema-zod': 'FormwerkZod', }; const pkgNameMap = { core: 'core', - 'schema-yup': 'schema-yup', - 'schema-zod': 'schema-zod', }; const formatExt: Partial> = {