mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
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:
@@ -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
77
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
src/components/ui/form/FormControl.vue
Normal file
26
src/components/ui/form/FormControl.vue
Normal 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>
|
||||
19
src/components/ui/form/FormDescription.vue
Normal file
19
src/components/ui/form/FormDescription.vue
Normal 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>
|
||||
21
src/components/ui/form/FormField.vue
Normal file
21
src/components/ui/form/FormField.vue
Normal 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>
|
||||
22
src/components/ui/form/FormItem.vue
Normal file
22
src/components/ui/form/FormItem.vue
Normal 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>
|
||||
28
src/components/ui/form/FormLabel.vue
Normal file
28
src/components/ui/form/FormLabel.vue
Normal 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>
|
||||
23
src/components/ui/form/FormMessage.vue
Normal file
23
src/components/ui/form/FormMessage.vue
Normal 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>
|
||||
6
src/components/ui/form/index.ts
Normal file
6
src/components/ui/form/index.ts
Normal 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'
|
||||
7
src/components/ui/form/injectionKeys.ts
Normal file
7
src/components/ui/form/injectionKeys.ts
Normal 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')
|
||||
35
src/components/ui/form/useFormField.ts
Normal file
35
src/components/ui/form/useFormField.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user