fix: upgrade vee-validate to v5 for native Zod 4 support

- Upgrade vee-validate from v4.15.1 to v5.0.0-beta.0 (Standard Schema)

- Remove custom toTypedSchema adapter (veeValidateZod.ts)

- Pass Zod schemas directly to validationSchema

- Fix SignInForm tests: use text-based selectors, remove try/catch anti-pattern

Amp-Thread-ID: https://ampcode.com/threads/T-019c7ec7-2a88-7165-9db4-ef656336407b
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-20 22:39:56 -08:00
parent d63cea48fc
commit d91a1e8c9b
9 changed files with 57 additions and 95 deletions

19
pnpm-lock.yaml generated
View File

@@ -271,8 +271,8 @@ catalogs:
specifier: ^30.0.0
version: 30.0.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1
specifier: 5.0.0-beta.0
version: 5.0.0-beta.0
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4
@@ -486,7 +486,7 @@ importers:
version: 0.8.2
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
version: 5.0.0-beta.0(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -3212,6 +3212,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@storybook/addon-docs@10.1.9':
resolution: {integrity: sha512-SvwEZ32lyk5p3PRmE3pmfAhs4HMiVo5zxjTBVmK9kgz9zGgWCTlikb56tJ998hVe52CFyCvt3I9rkHeYMCKPww==}
peerDependencies:
@@ -8115,8 +8118,8 @@ packages:
typescript:
optional: true
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
vee-validate@5.0.0-beta.0:
resolution: {integrity: sha512-uGIRnODDMM0A8Weu8AJcZFFJceUpgbSX6G4UYZgWhBc90VcXDK+v7yO16G+sj+6vU1eML11M2BH4HxwoPE62rw==}
peerDependencies:
vue: ^3.4.26
@@ -11206,6 +11209,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@storybook/addon-docs@10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@8.0.0-beta.13(@types/node@24.10.4)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.3)
@@ -17080,8 +17085,10 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
vee-validate@5.0.0-beta.0(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
'@vue/devtools-api': 7.7.9
type-fest: 4.41.0
vue: 3.5.13(typescript@5.9.3)

View File

@@ -91,7 +91,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vee-validate: ^4.15.1
vee-validate: 5.0.0-beta.0
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2

View File

@@ -10,7 +10,6 @@
</template>
<script setup lang="ts">
import { toTypedSchema } from '@/utils/veeValidateZod'
import { useForm } from 'vee-validate'
import { ref } from 'vue'
@@ -31,7 +30,7 @@ const { handleSubmit } = useForm({
confirmPassword: '',
password: ''
},
validationSchema: toTypedSchema(updatePasswordSchema)
validationSchema: updatePasswordSchema
})
const onSubmit = handleSubmit(async (submittedValues) => {

View File

@@ -80,7 +80,6 @@
</template>
<script setup lang="ts">
import { toTypedSchema } from '@/utils/veeValidateZod'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { useForm } from 'vee-validate'
@@ -119,7 +118,7 @@ const { defineField, errors, validate } = useForm({
initialValues: {
apiKey: ''
},
validationSchema: toTypedSchema(apiKeySchema)
validationSchema: apiKeySchema
})
const [apiKey, apiKeyAttrs] = defineField('apiKey')

View File

@@ -84,48 +84,45 @@ describe('SignInForm', () => {
}
describe('Forgot Password Link', () => {
function findForgotPasswordButton(wrapper: VueWrapper<ComponentInstance>) {
return wrapper
.findAll('button[type="button"]')
.find((btn) =>
btn.text().includes(enMessages.auth.login.forgotPassword)
)!
}
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
const forgotBtn = findForgotPasswordButton(wrapper)
expect(forgotBtn.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
const forgotBtn = findForgotPasswordButton(wrapper)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await forgotBtn.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
@@ -135,11 +132,8 @@ describe('SignInForm', () => {
.find('#comfy-org-sign-in-email')
.setValue('test@example.com')
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
await forgotPasswordSpan.trigger('click')
const forgotBtn = findForgotPasswordButton(wrapper)
await forgotBtn.trigger('click')
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
@@ -157,28 +151,37 @@ describe('SignInForm', () => {
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
const wrapper = mountComponent(
{},
{
global: {
plugins: [
PrimeVue,
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
ToastService
],
stubs: {
ProgressSpinner: { template: '<div data-testid="spinner" />' }
}
}
}
)
await nextTick()
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.find('button').exists()).toBe(false)
} catch (error) {
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
expect(wrapper.find('[data-testid="spinner"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').exists()).toBe(false)
})
it('shows button when not loading', () => {
it('shows submit button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
})
})

View File

@@ -93,7 +93,6 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { toTypedSchema } from '@/utils/veeValidateZod'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -114,7 +113,7 @@ const { errors, meta, validate, values } = useForm<SignInData>({
password: ''
},
validateOnMount: true,
validationSchema: toTypedSchema(signInSchema)
validationSchema: signInSchema
})
const onSubmit = useThrottleFn(async (event: Event) => {

View File

@@ -36,7 +36,6 @@
</template>
<script setup lang="ts">
import { toTypedSchema } from '@/utils/veeValidateZod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
@@ -71,7 +70,7 @@ const { errors, handleSubmit, meta } = useForm<SignUpData>({
password: ''
},
validateOnMount: true,
validationSchema: toTypedSchema(signUpSchema)
validationSchema: signUpSchema
})
const onSubmit = useThrottleFn(

View File

@@ -71,7 +71,6 @@
</template>
<script setup lang="ts">
import { toTypedSchema } from '@/utils/veeValidateZod'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Password from 'primevue/password'
@@ -106,7 +105,7 @@ const { defineField, errors, handleSubmit, meta } = useForm<SignInData>({
password: ''
},
validateOnMount: true,
validationSchema: toTypedSchema(signInSchema)
validationSchema: signInSchema
})
const [email, emailAttrs] = defineField('email')

View File

@@ -1,43 +0,0 @@
import type { TypedSchema, TypedSchemaError } from 'vee-validate'
import type { z } from 'zod'
const buildTypedSchemaErrors = (issues: z.ZodIssue[]): TypedSchemaError[] => {
const groupedErrors = new Map<string, TypedSchemaError>()
for (const issue of issues) {
const path = issue.path.length > 0 ? issue.path.join('.') : ''
const existingError = groupedErrors.get(path)
if (existingError) {
existingError.errors.push(issue.message)
continue
}
groupedErrors.set(path, {
path: path || undefined,
errors: [issue.message]
})
}
return [...groupedErrors.values()]
}
export const toTypedSchema = <TSchema extends z.ZodType>(
schema: TSchema
): TypedSchema<z.input<TSchema>, z.output<TSchema>> => ({
__type: 'VVTypedSchema',
parse: async (values) => {
const result = await schema.safeParseAsync(values)
if (result.success) {
return {
value: result.data,
errors: []
}
}
return {
errors: buildTypedSchemaErrors(result.error.issues)
}
}
})