feat: migrate auth forms to vee-validate

- replace @primevue/forms usage with vee-validate + @vee-validate/zod

- add shared ui form primitives for field/item/control/message wiring

- update auth form tests and remove legacy forms dependencies

Amp-Thread-ID: https://ampcode.com/threads/T-019c7e06-730d-729e-8602-1bc1dffe4801
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-20 19:10:22 -08:00
parent f7a83f6dfa
commit 1ad4327079
20 changed files with 539 additions and 371 deletions

View File

@@ -61,11 +61,9 @@
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",
"@primeuix/styled": "catalog:",
"@primeuix/utils": "catalog:",
"@primevue/core": "catalog:",
"@primevue/forms": "catalog:",
"@primevue/icons": "catalog:",
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
@@ -77,6 +75,7 @@
"@tiptap/extension-table-header": "^2.10.4",
"@tiptap/extension-table-row": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@xterm/addon-fit": "^0.10.0",
@@ -106,6 +105,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",

77
pnpm-lock.yaml generated
View File

@@ -15,15 +15,9 @@ catalogs:
'@eslint/js':
specifier: ^9.39.1
version: 9.39.1
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.79
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind4':
specifier: ^1.2.0
version: 1.2.1
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0
@@ -48,9 +42,6 @@ catalogs:
'@playwright/test':
specifier: ^1.58.1
version: 1.58.1
'@primeuix/forms':
specifier: 0.0.2
version: 0.0.2
'@primeuix/styled':
specifier: 0.3.2
version: 0.3.2
@@ -60,9 +51,6 @@ catalogs:
'@primevue/core':
specifier: ^4.2.5
version: 4.2.5
'@primevue/forms':
specifier: ^4.2.5
version: 4.2.5
'@primevue/icons':
specifier: 4.2.5
version: 4.2.5
@@ -108,6 +96,9 @@ catalogs:
'@types/three':
specifier: ^0.169.0
version: 0.169.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.3
@@ -141,9 +132,6 @@ catalogs:
cva:
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4
dompurify:
specifier: ^3.3.1
version: 3.3.1
dotenv:
specifier: ^16.4.5
version: 16.6.1
@@ -276,6 +264,9 @@ catalogs:
unplugin-vue-components:
specifier: ^30.0.0
version: 30.0.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4
@@ -356,9 +347,6 @@ importers:
'@iconify/json':
specifier: 'catalog:'
version: 2.2.380
'@primeuix/forms':
specifier: 'catalog:'
version: 0.0.2
'@primeuix/styled':
specifier: 'catalog:'
version: 0.3.2
@@ -368,9 +356,6 @@ importers:
'@primevue/core':
specifier: 'catalog:'
version: 4.2.5(vue@3.5.13(typescript@5.9.3))
'@primevue/forms':
specifier: 'catalog:'
version: 4.2.5(vue@3.5.13(typescript@5.9.3))
'@primevue/icons':
specifier: 'catalog:'
version: 4.2.5(vue@3.5.13(typescript@5.9.3))
@@ -404,6 +389,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^2.10.4
version: 2.10.4
'@vee-validate/zod':
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.24.1)
'@vueuse/core':
specifier: 'catalog:'
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
@@ -491,6 +479,9 @@ importers:
typegpu:
specifier: 'catalog:'
version: 0.8.2
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -2804,10 +2795,6 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@primeuix/forms@0.0.2':
resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==}
engines: {node: '>=12.11.0'}
'@primeuix/styled@0.3.2':
resolution: {integrity: sha512-ColZes0+/WKqH4ob2x8DyNYf1NENpe5ZguOvx5yCLxaP8EIMVhLjWLO/3umJiDnQU4XXMLkn2mMHHw+fhTX/mw==}
engines: {node: '>=12.11.0'}
@@ -2822,10 +2809,6 @@ packages:
peerDependencies:
vue: ^3.3.0
'@primevue/forms@4.2.5':
resolution: {integrity: sha512-5jarJQ9Qv32bOo/0tY5bqR3JZI6+YmmoUQ2mjhVSbVElQsE4FNfhT7a7JwF+xgBPMPc8KWGNA1QB248HhPNVAg==}
engines: {node: '>=12.11.0'}
'@primevue/icons@4.2.5':
resolution: {integrity: sha512-WFbUMZhQkXf/KmwcytkjGVeJ9aGEDXjP3uweOjQZMmRdEIxFnqYYpd90wE90JE1teZn3+TVnT4ZT7ejGyEXnFQ==}
engines: {node: '>=12.11.0'}
@@ -3893,6 +3876,11 @@ packages:
peerDependencies:
valibot: ^1.2.0
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -8127,6 +8115,11 @@ packages:
typescript:
optional: true
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.4.26
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -10892,10 +10885,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@primeuix/forms@0.0.2':
dependencies:
'@primeuix/utils': 0.3.2
'@primeuix/styled@0.3.2':
dependencies:
'@primeuix/utils': 0.3.2
@@ -10908,14 +10897,6 @@ snapshots:
'@primeuix/utils': 0.3.2
vue: 3.5.13(typescript@5.9.3)
'@primevue/forms@4.2.5(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@primeuix/forms': 0.0.2
'@primeuix/utils': 0.3.2
'@primevue/core': 4.2.5(vue@3.5.13(typescript@5.9.3))
transitivePeerDependencies:
- vue
'@primevue/icons@4.2.5(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@primeuix/utils': 0.3.2
@@ -11943,6 +11924,14 @@ snapshots:
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.24.1)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
zod: 3.24.1
transitivePeerDependencies:
- vue
'@vitejs/plugin-vue@6.0.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))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
@@ -17099,6 +17088,12 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
type-fest: 4.41.0
vue: 3.5.13(typescript@5.9.3)
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3

View File

@@ -17,11 +17,9 @@ catalog:
'@nx/vite': 22.2.6
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
'@primevue/core': ^4.2.5
'@primevue/forms': ^4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
@@ -37,6 +35,7 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vee-validate/zod': ^4.15.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
@@ -93,6 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vee-validate: ^4.15.1
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2

View File

@@ -1,22 +1,17 @@
<template>
<Form
class="flex w-96 flex-col gap-6"
:resolver="zodResolver(updatePasswordSchema)"
@submit="onSubmit"
>
<form class="flex w-96 flex-col gap-6" @submit="onSubmit">
<PasswordFields />
<!-- Submit Button -->
<Button type="submit" class="mt-4 h-10 font-medium" :loading="loading">
{{ $t('userSettings.updatePassword') }}
</Button>
</Form>
</form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
@@ -31,15 +26,23 @@ const { onSuccess } = defineProps<{
onSuccess: () => void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
const { handleSubmit } = useForm({
initialValues: {
confirmPassword: '',
password: ''
},
validationSchema: toTypedSchema(updatePasswordSchema)
})
const onSubmit = handleSubmit(async (submittedValues) => {
if (submittedValues.password) {
loading.value = true
try {
await authActions.updatePassword(event.values.password)
await authActions.updatePassword(submittedValues.password)
onSuccess()
} finally {
loading.value = false
}
}
}
})
</script>

View File

@@ -1,6 +1,5 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
@@ -69,7 +68,7 @@ describe('ApiKeyForm', () => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: { Button, Form, InputText, Message }
components: { Button, InputText, Message }
},
props
})

View File

@@ -18,14 +18,9 @@
</div>
</div>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(apiKeySchema)"
@submit="onSubmit"
>
<Message v-if="$form.apiKey?.invalid" severity="error" class="mb-4">
{{ $form.apiKey.error.message }}
<form class="flex flex-col gap-6" @submit="onSubmit">
<Message v-if="errors.apiKey" severity="error" class="mb-4">
{{ errors.apiKey }}
</Message>
<div class="flex flex-col gap-2">
@@ -37,13 +32,14 @@
</label>
<div class="flex flex-col gap-2">
<InputText
pt:root:id="comfy-org-api-key"
pt:root:autocomplete="off"
id="comfy-org-api-key"
v-model="apiKey"
v-bind="apiKeyAttrs"
autocomplete="off"
class="h-10"
name="apiKey"
type="password"
:placeholder="t('auth.apiKey.placeholder')"
:invalid="$form.apiKey?.invalid"
:invalid="Boolean(errors.apiKey)"
/>
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
@@ -79,16 +75,15 @@
{{ t('g.save') }}
</Button>
</div>
</Form>
</form>
</div>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { toTypedSchema } from '@vee-validate/zod'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { useForm } from 'vee-validate'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -120,9 +115,24 @@ const emit = defineEmits<{
(e: 'success'): void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
await apiKeyStore.storeApiKey(event.values.apiKey)
const { defineField, errors, validate } = useForm({
initialValues: {
apiKey: ''
},
validationSchema: toTypedSchema(apiKeySchema)
})
const [apiKey, apiKeyAttrs] = defineField('apiKey')
const onSubmit = async (event: Event) => {
event.preventDefault()
const { valid, values: submittedValues } = await validate()
if (!valid) {
return
}
if (submittedValues?.apiKey) {
await apiKeyStore.storeApiKey(submittedValues.apiKey)
emit('success')
}
}

View File

@@ -1,111 +1,119 @@
<template>
<!-- Password Field -->
<FormField v-slot="$field" name="password" class="flex flex-col gap-2">
<div class="mb-2 flex items-center justify-between">
<label
class="text-base font-medium opacity-80"
for="comfy-org-sign-up-password"
>
{{ t('auth.signup.passwordLabel') }}
</label>
</div>
<Password
v-model="password"
input-id="comfy-org-sign-up-password"
pt:pc-input-text:root:autocomplete="new-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.signup.passwordPlaceholder')"
:class="{ 'p-invalid': $field.invalid }"
fluid
class="h-10"
/>
<div class="flex flex-col gap-1">
<small v-if="$field.dirty || $field.invalid" class="text-sm">
{{ t('validation.password.requirements') }}:
<ul class="mt-1 space-y-1">
<li
:class="{
'text-red-500': !passwordChecks.length
}"
>
{{ t('validation.password.minLength') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.uppercase
}"
>
{{ t('validation.password.uppercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.lowercase
}"
>
{{ t('validation.password.lowercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.number
}"
>
{{ t('validation.password.number') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.special
}"
>
{{ t('validation.password.special') }}
</li>
</ul>
</small>
</div>
<FormField v-slot="{ componentField, meta }" name="password">
<FormItem class="flex flex-col gap-2">
<div class="mb-2 flex items-center justify-between">
<FormLabel class="text-base font-medium opacity-80">
{{ t('auth.signup.passwordLabel') }}
</FormLabel>
</div>
<FormControl>
<Password
v-bind="componentField"
pt:pc-input-text:root:autocomplete="new-password"
:feedback="false"
toggle-mask
:placeholder="t('auth.signup.passwordPlaceholder')"
:class="{ 'p-invalid': Boolean(errors.password) }"
fluid
class="h-10"
/>
</FormControl>
<div class="flex flex-col gap-1">
<small v-if="meta.dirty || Boolean(errors.password)" class="text-sm">
{{ t('validation.password.requirements') }}:
<ul class="mt-1 space-y-1">
<li
:class="{
'text-red-500': !passwordChecks.length
}"
>
{{ t('validation.password.minLength') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.uppercase
}"
>
{{ t('validation.password.uppercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.lowercase
}"
>
{{ t('validation.password.lowercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.number
}"
>
{{ t('validation.password.number') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.special
}"
>
{{ t('validation.password.special') }}
</li>
</ul>
</small>
</div>
</FormItem>
</FormField>
<!-- Confirm Password Field -->
<FormField v-slot="$field" name="confirmPassword" class="flex flex-col gap-2">
<label
class="mb-2 text-base font-medium opacity-80"
for="comfy-org-sign-up-confirm-password"
>
{{ t('auth.login.confirmPasswordLabel') }}
</label>
<Password
name="confirmPassword"
input-id="comfy-org-sign-up-confirm-password"
pt:pc-input-text:root:autocomplete="new-password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.confirmPasswordPlaceholder')"
:class="{ 'p-invalid': $field.invalid }"
fluid
class="h-10"
/>
<small v-if="$field.error" class="text-red-500">{{
$field.error.message
}}</small>
<FormField v-slot="{ componentField }" name="confirmPassword">
<FormItem class="flex flex-col gap-2">
<FormLabel class="mb-2 text-base font-medium opacity-80">
{{ t('auth.login.confirmPasswordLabel') }}
</FormLabel>
<FormControl>
<Password
v-bind="componentField"
pt:pc-input-text:root:autocomplete="new-password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.confirmPasswordPlaceholder')"
:class="{ 'p-invalid': Boolean(errors.confirmPassword) }"
fluid
class="h-10"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>
<script setup lang="ts">
import { FormField } from '@primevue/forms'
import Password from 'primevue/password'
import { computed, ref } from 'vue'
import { useFormContext } from 'vee-validate'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const password = ref('')
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
type PasswordFormValues = {
confirmPassword: string
password: string
}
const { t } = useI18n()
const { errors, values } = useFormContext<PasswordFormValues>()
// TODO: Use dynamic form to better organize the password checks.
// Ref: https://primevue.org/forms/#dynamic
const passwordChecks = computed(() => ({
length: password.value.length >= 8 && password.value.length <= 32,
uppercase: /[A-Z]/.test(password.value),
lowercase: /[a-z]/.test(password.value),
number: /\d/.test(password.value),
special: /[^A-Za-z0-9]/.test(password.value)
length: values.password.length >= 8 && values.password.length <= 32,
uppercase: /[A-Z]/.test(values.password),
lowercase: /[a-z]/.test(values.password),
number: /\d/.test(values.password),
special: /[^A-Za-z0-9]/.test(values.password)
}))
</script>

View File

@@ -1,7 +1,5 @@
import { Form } from '@primevue/forms'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
@@ -78,14 +76,7 @@ describe('SignInForm', () => {
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
plugins: [PrimeVue, i18n, ToastService]
},
props,
...options
@@ -138,66 +129,27 @@ describe('SignInForm', () => {
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
it('sends reset email when link is clicked with a valid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
await wrapper
.find('#comfy-org-sign-in-email')
.setValue('test@example.com')
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
await wrapper.find('form').trigger('submit')
await nextTick()
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
@@ -211,9 +163,8 @@ describe('SignInForm', () => {
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
expect(wrapper.find('button').exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
@@ -227,7 +178,7 @@ describe('SignInForm', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
expect(wrapper.find('button').exists()).toBe(true)
})
})
@@ -238,7 +189,6 @@ describe('SignInForm', () => {
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
@@ -246,20 +196,10 @@ describe('SignInForm', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
@@ -267,7 +207,6 @@ describe('SignInForm', () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById to track focus
@@ -291,7 +230,6 @@ describe('SignInForm', () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById
@@ -304,11 +242,8 @@ describe('SignInForm', () => {
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})

View File

@@ -1,63 +1,65 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signInSchema)"
@submit="onSubmit"
>
<form class="flex flex-col gap-6" @submit="onSubmit">
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label class="mb-2 text-base font-medium opacity-80" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<FormField v-slot="{ componentField }" name="email">
<div class="flex flex-col gap-2">
<label
class="mb-2 text-base font-medium opacity-80"
:for="emailInputId"
>
{{ t('auth.login.emailLabel') }}
</label>
<InputText
v-bind="componentField"
:id="emailInputId"
autocomplete="email"
class="h-10"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="Boolean(errors.email)"
/>
<small v-if="errors.email" class="text-red-500">{{
errors.email
}}</small>
</div>
</FormField>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center justify-between">
<label
class="text-base font-medium opacity-80"
for="comfy-org-sign-in-password"
>
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="cursor-pointer text-base font-medium text-muted select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
</span>
<FormField v-slot="{ componentField }" name="password">
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center justify-between">
<label
class="text-base font-medium opacity-80"
for="comfy-org-sign-in-password"
>
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="cursor-pointer text-base font-medium text-muted select-none"
:class="{
'text-link-disabled': !values.email || Boolean(errors.email)
}"
@click="handleForgotPassword(values.email, !errors.email)"
>
{{ t('auth.login.forgotPassword') }}
</span>
</div>
<Password
v-bind="componentField"
input-id="comfy-org-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': Boolean(errors.password) }"
fluid
class="h-10"
/>
<small v-if="errors.password" class="text-red-500">{{
errors.password
}}</small>
</div>
<Password
input-id="comfy-org-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.password?.invalid" class="text-red-500">{{
$form.password.error.message
}}</small>
</div>
</FormField>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="mx-auto h-8 w-8" />
@@ -65,26 +67,26 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
:disabled="!meta.valid"
>
{{ t('auth.login.loginButton') }}
</Button>
</Form>
</form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { useForm } from 'vee-validate'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { FormField } from '@/components/ui/form'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
@@ -103,9 +105,23 @@ const emit = defineEmits<{
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
const { errors, meta, validate, values } = useForm<SignInData>({
initialValues: {
email: '',
password: ''
},
validateOnMount: true,
validationSchema: toTypedSchema(signInSchema)
})
const onSubmit = useThrottleFn(async (event: Event) => {
event.preventDefault()
const { valid, values: submittedValues } = await validate()
if (valid && submittedValues?.email && submittedValues.password) {
emit('submit', {
email: submittedValues.email,
password: submittedValues.password
})
}
}, 1_500)

View File

@@ -1,29 +1,23 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signUpSchema)"
@submit="onSubmit"
>
<form class="flex flex-col gap-6" @submit="onSubmit">
<!-- Email Field -->
<FormField v-slot="$field" name="email" class="flex flex-col gap-2">
<label
class="mb-2 text-base font-medium opacity-80"
for="comfy-org-sign-up-email"
>
{{ t('auth.signup.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-up-email"
pt:root:autocomplete="email"
class="h-10"
type="text"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="$field.invalid"
/>
<small v-if="$field.error" class="text-red-500">{{
$field.error.message
}}</small>
<FormField v-slot="{ componentField }" name="email">
<FormItem class="flex flex-col gap-2">
<FormLabel class="mb-2 text-base font-medium opacity-80">
{{ t('auth.signup.emailLabel') }}
</FormLabel>
<FormControl>
<InputText
v-bind="componentField"
autocomplete="email"
class="h-10"
type="text"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="Boolean(errors.email)"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<PasswordFields />
@@ -34,24 +28,30 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
:disabled="!meta.valid"
>
{{ t('auth.signup.signUpButton') }}
</Button>
</Form>
</form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form, FormField } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { useForm } from 'vee-validate'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -66,9 +66,20 @@ const emit = defineEmits<{
submit: [values: SignUpData]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
}
}, 1_500)
const { errors, handleSubmit, meta } = useForm<SignUpData>({
initialValues: {
confirmPassword: '',
email: '',
password: ''
},
validateOnMount: true,
validationSchema: toTypedSchema(signUpSchema)
})
const onSubmit = useThrottleFn(
handleSubmit((submittedValues) => {
emit('submit', submittedValues)
}),
1_500
)
</script>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Slot } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
import { useFormField } from './useFormField'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { describedBy, errorMessage, formItemId } = useFormField()
</script>
<template>
<Slot
:id="formItemId"
:aria-describedby="describedBy"
:aria-invalid="Boolean(errorMessage)"
:class="cn(className)"
>
<slot />
</Slot>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useFormField } from './useFormField'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { formDescriptionId } = useFormField()
</script>
<template>
<p :id="formDescriptionId" :class="cn('text-[0.8rem] text-muted', className)">
<slot />
</p>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { Field } from 'vee-validate'
import { computed, provide } from 'vue'
import { FORM_FIELD_NAME_INJECTION_KEY } from './injectionKeys'
const props = defineProps<{
name: string
}>()
provide(
FORM_FIELD_NAME_INJECTION_KEY,
computed(() => props.name)
)
</script>
<template>
<Field v-slot="slotProps" :name="props.name">
<slot v-bind="slotProps" />
</Field>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useId, provide } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { FORM_ITEM_ID_INJECTION_KEY } from './injectionKeys'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const id = useId()
provide(FORM_ITEM_ID_INJECTION_KEY, id)
</script>
<template>
<div :class="cn('space-y-2', className)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useFormField } from './useFormField'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { errorMessage, formItemId } = useFormField()
</script>
<template>
<label
:for="formItemId"
:class="
cn(
'text-sm leading-none font-medium',
errorMessage && 'text-danger',
className
)
"
>
<slot />
</label>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useFormField } from './useFormField'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { errorMessage, formMessageId } = useFormField()
</script>
<template>
<p
v-if="errorMessage"
:id="formMessageId"
:class="cn('text-[0.8rem] font-medium text-danger', className)"
>
{{ errorMessage }}
</p>
</template>

View File

@@ -0,0 +1,6 @@
export { default as FormControl } from './FormControl.vue'
export { default as FormDescription } from './FormDescription.vue'
export { default as FormField } from './FormField.vue'
export { default as FormItem } from './FormItem.vue'
export { default as FormLabel } from './FormLabel.vue'
export { default as FormMessage } from './FormMessage.vue'

View File

@@ -0,0 +1,7 @@
import type { InjectionKey, Ref } from 'vue'
export const FORM_FIELD_NAME_INJECTION_KEY: InjectionKey<Ref<string>> =
Symbol('FORM_FIELD_NAME')
export const FORM_ITEM_ID_INJECTION_KEY: InjectionKey<string> =
Symbol('FORM_ITEM_ID')

View File

@@ -0,0 +1,35 @@
import { useFieldError } from 'vee-validate'
import { computed, inject } from 'vue'
import {
FORM_FIELD_NAME_INJECTION_KEY,
FORM_ITEM_ID_INJECTION_KEY
} from './injectionKeys'
export const useFormField = () => {
const fieldName = inject(FORM_FIELD_NAME_INJECTION_KEY)
const itemId = inject(FORM_ITEM_ID_INJECTION_KEY)
if (!fieldName || !itemId) {
throw new Error('useFormField must be used within FormField and FormItem')
}
const errorMessage = useFieldError(fieldName)
const formItemId = `${itemId}-form-item`
const formDescriptionId = `${itemId}-form-item-description`
const formMessageId = `${itemId}-form-item-message`
const describedBy = computed(() =>
errorMessage.value
? `${formDescriptionId} ${formMessageId}`
: formDescriptionId
)
return {
errorMessage,
formDescriptionId,
formItemId,
formMessageId,
describedBy,
name: fieldName
}
}

View File

@@ -1,10 +1,5 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signInSchema)"
@submit="onSubmit"
>
<form class="flex flex-col gap-6" @submit="onSubmit">
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label class="mb-2 text-base font-medium opacity-80" :for="emailInputId">
@@ -12,16 +7,15 @@
</label>
<InputText
:id="emailInputId"
v-model="email"
v-bind="emailAttrs"
autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="$form.email?.invalid"
:invalid="Boolean(errors.email)"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
<small v-if="errors.email" class="text-red-500">{{ errors.email }}</small>
</div>
<!-- Password Field -->
@@ -35,18 +29,19 @@
</label>
</div>
<Password
v-model="password"
v-bind="passwordAttrs"
input-id="cloud-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
:class="{ 'p-invalid': Boolean(errors.password) }"
fluid
class="h-10"
/>
<small v-if="$form.password?.invalid" class="text-red-500">{{
$form.password.error.message
<small v-if="errors.password" class="text-red-500">{{
errors.password
}}</small>
<router-link
@@ -68,21 +63,20 @@
v-else
type="submit"
class="mt-4 h-10 font-medium text-white"
:disabled="!$form.valid"
:disabled="!meta.valid"
>
{{ t('auth.login.loginButton') }}
</Button>
</Form>
</form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { toTypedSchema } from '@vee-validate/zod'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useForm } from 'vee-validate'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -106,11 +100,21 @@ const emit = defineEmits<{
const emailInputId = 'cloud-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const { defineField, errors, handleSubmit, meta } = useForm<SignInData>({
initialValues: {
email: '',
password: ''
},
validateOnMount: true,
validationSchema: toTypedSchema(signInSchema)
})
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
const onSubmit = handleSubmit((submittedValues) => {
emit('submit', submittedValues)
})
</script>
<style scoped>
:deep(.p-inputtext) {