Compare commits

...

7 Commits

Author SHA1 Message Date
coderabbitai[bot]
b42c7878b8 📝 Add docstrings to drjkl/vee-validate
Docstrings generation was requested by @DrJKL.

The following files were modified:

* `src/components/ui/form/useFormField.ts`
* `src/platform/assets/utils/createAssetWidget.ts`
* `src/platform/workflow/validation/schemas/workflowSchema.ts`
* `src/scripts/changeTracker.ts`
* `src/stores/subgraphStore.ts`

These files were kept as they were:
* `src/platform/assets/utils/createModelNodeFromAsset.ts`

These files were ignored:
* `src/components/dialog/content/signin/ApiKeyForm.test.ts`
* `src/components/dialog/content/signin/SignInForm.test.ts`
* `src/platform/workflow/validation/schemas/workflowSchema.test.ts`

These file types are not supported:
* `package.json`
* `pnpm-workspace.yaml`
* `src/components/dialog/content/UpdatePasswordContent.vue`
* `src/components/dialog/content/signin/ApiKeyForm.vue`
* `src/components/dialog/content/signin/PasswordFields.vue`
* `src/components/dialog/content/signin/SignInForm.vue`
* `src/components/dialog/content/signin/SignUpForm.vue`
* `src/components/ui/form/FormControl.vue`
* `src/components/ui/form/FormDescription.vue`
* `src/components/ui/form/FormField.vue`
* `src/components/ui/form/FormItem.vue`
* `src/components/ui/form/FormLabel.vue`
* `src/components/ui/form/FormMessage.vue`
* `src/platform/cloud/onboarding/components/CloudSignInForm.vue`
2026-02-23 02:18:59 +00:00
Alexander Brown
68e33760b0 fix: clean up sign-in form tests and loading state
- Remove per-test ProgressSpinner stub; stub globally in mountComponent

- Use findComponent(ProgressSpinner) consistently in loading tests

- Replace non-null assertion with throwing guard in findForgotPasswordButton

Amp-Thread-ID: https://ampcode.com/threads/T-019c7f35-4320-75dc-be40-98526259f2ae
Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 00:05:22 -08:00
Alexander Brown
d91a1e8c9b 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>
2026-02-20 22:39:56 -08:00
Alexander Brown
d63cea48fc fix: address CodeRabbit review feedback
- Remove barrel file (ui/form/index.ts), use direct imports

- Use text-muted-foreground design token in FormDescription

- Replace span with Button for keyboard-accessible forgot password

- Use function declaration for useFormField composable

- Guard against undefined values.password in PasswordFields

Amp-Thread-ID: https://ampcode.com/threads/T-019c7ebc-b163-72d9-a20e-ed38717b9cbb
Co-authored-by: Amp <amp@ampcode.com>
2026-02-20 21:52:00 -08:00
Alexander Brown
296cfced2c fix: migrate to zod 4 and resolve peer warnings
Amp-Thread-ID: https://ampcode.com/threads/T-019c7e95-acd0-76b9-9c37-7f825e8e2a86
Co-authored-by: Amp <amp@ampcode.com>
2026-02-20 21:41:22 -08:00
Alexander Brown
e39aa6a2ca fix: satisfy knip checks for vee-validate migration
- remove stale @primeuix/forms knip ignore dependency

- use FormDescription in password field requirements block

Amp-Thread-ID: https://ampcode.com/threads/T-019c7e06-730d-729e-8602-1bc1dffe4801
Co-authored-by: Amp <amp@ampcode.com>
2026-02-20 19:14:02 -08:00
Alexander Brown
1ad4327079 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>
2026-02-20 19:10:22 -08:00
36 changed files with 738 additions and 509 deletions

View File

@@ -29,7 +29,6 @@ const config: KnipConfig = {
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'

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:",
@@ -106,6 +104,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",
@@ -194,6 +193,18 @@
"pnpm": {
"overrides": {
"vite": "catalog:"
},
"packageExtensions": {
"openai@4.104.0": {
"peerDependencies": {
"zod": "^3.23.8 || ^4.0.0"
}
}
},
"peerDependencyRules": {
"allowedVersions": {
"zod": "^4.0.0"
}
}
}
}

126
pnpm-lock.yaml generated
View File

@@ -48,9 +48,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 +57,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
@@ -276,6 +270,9 @@ catalogs:
unplugin-vue-components:
specifier: ^30.0.0
version: 30.0.0
vee-validate:
specifier: 5.0.0-beta.0
version: 5.0.0-beta.0
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4
@@ -316,18 +313,20 @@ catalogs:
specifier: ^13.6.27
version: 13.6.27
zod:
specifier: ^3.23.8
version: 3.24.1
specifier: ^4.3.6
version: 4.3.6
zod-to-json-schema:
specifier: ^3.24.1
version: 3.24.1
specifier: ^3.25.1
version: 3.25.1
zod-validation-error:
specifier: ^3.3.0
version: 3.3.0
specifier: ^5.0.0
version: 5.0.0
overrides:
vite: 8.0.0-beta.13
packageExtensionsChecksum: sha256-49yZu1KDcXKFdjguhO09kXpBzb+YokPynnw5gHOLOps=
importers:
.:
@@ -356,9 +355,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 +364,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))
@@ -491,6 +484,9 @@ importers:
typegpu:
specifier: 'catalog:'
version: 0.8.2
vee-validate:
specifier: 'catalog:'
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)
@@ -511,10 +507,10 @@ importers:
version: 13.6.27
zod:
specifier: 'catalog:'
version: 3.24.1
version: 4.3.6
zod-validation-error:
specifier: 'catalog:'
version: 3.3.0(zod@3.24.1)
version: 5.0.0(zod@4.3.6)
devDependencies:
'@eslint/js':
specifier: 'catalog:'
@@ -524,7 +520,7 @@ importers:
version: 4.1.0(eslint@9.39.1(jiti@2.6.1))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.2.0(eslint@9.39.1(jiti@2.6.1)))(yaml-eslint-parser@1.3.0)
'@lobehub/i18n-cli':
specifier: 'catalog:'
version: 1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3))(ws@8.18.3)(zod@3.24.1)
version: 1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3))(ws@8.18.3)(zod@4.3.6)
'@nx/eslint':
specifier: 'catalog:'
version: 22.2.6(@babel/traverse@7.28.5)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.2.6)
@@ -737,7 +733,7 @@ importers:
version: 2.0.0
zod-to-json-schema:
specifier: 'catalog:'
version: 3.24.1(zod@3.24.1)
version: 3.25.1(zod@4.3.6)
apps/desktop-ui:
dependencies:
@@ -2804,10 +2800,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 +2814,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'}
@@ -3224,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:
@@ -8127,6 +8118,11 @@ packages:
typescript:
optional: true
vee-validate@5.0.0-beta.0:
resolution: {integrity: sha512-uGIRnODDMM0A8Weu8AJcZFFJceUpgbSX6G4UYZgWhBc90VcXDK+v7yO16G+sj+6vU1eML11M2BH4HxwoPE62rw==}
peerDependencies:
vue: ^3.4.26
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -8276,6 +8272,9 @@ packages:
vue-component-type-helpers@3.2.4:
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
vue-component-type-helpers@3.2.5:
resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -8566,22 +8565,19 @@ packages:
zip-dir@2.0.0:
resolution: {integrity: sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==}
zod-to-json-schema@3.24.1:
resolution: {integrity: sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==}
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
zod: ^3.24.1
zod: ^3.25 || ^4
zod-validation-error@3.3.0:
resolution: {integrity: sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==}
zod-validation-error@5.0.0:
resolution: {integrity: sha512-hmk+pkyKq7Q71PiWVSDUc3VfpzpvcRHZ3QPw9yEMVvmtCekaMeOHnbr3WbxfrgEnQTv6haGP4cmv0Ojmihzsxw==}
engines: {node: '>=18.0.0'}
peerDependencies:
zod: ^3.18.0
zod: ^3.25.0 || ^4.0.0
zod@3.24.1:
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
zod@4.2.1:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
@@ -10241,7 +10237,7 @@ snapshots:
- react-devtools-core
- utf-8-validate
'@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3))(ws@8.18.3)(zod@3.24.1)':
'@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@lobehub/cli-ui': 1.13.0(@types/react@19.1.9)
'@yutengjing/eld': 0.0.2
@@ -10260,7 +10256,7 @@ snapshots:
json-stable-stringify: 1.3.0
just-diff: 6.0.2
lodash-es: 4.17.21
openai: 4.104.0(ws@8.18.3)(zod@3.24.1)
openai: 4.104.0(ws@8.18.3)(zod@4.3.6)
p-map: 7.0.3
pangu: 4.0.7
react: 19.2.3
@@ -10892,10 +10888,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 +10900,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
@@ -11225,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)
@@ -11322,7 +11308,7 @@ snapshots:
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)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.4
vue-component-type-helpers: 3.2.5
'@swc/helpers@0.5.17':
dependencies:
@@ -14620,7 +14606,7 @@ snapshots:
smol-toml: 1.5.2
strip-json-comments: 5.0.3
typescript: 5.9.3
zod: 4.2.1
zod: 4.3.6
known-css-properties@0.37.0: {}
@@ -15517,7 +15503,7 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@4.104.0(ws@8.18.3)(zod@3.24.1):
openai@4.104.0(ws@8.18.3)(zod@4.3.6):
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
@@ -15528,7 +15514,7 @@ snapshots:
node-fetch: 2.7.0
optionalDependencies:
ws: 8.18.3
zod: 3.24.1
zod: 4.3.6
transitivePeerDependencies:
- encoding
@@ -17099,6 +17085,14 @@ snapshots:
optionalDependencies:
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)
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -17362,6 +17356,8 @@ snapshots:
vue-component-type-helpers@3.2.4: {}
vue-component-type-helpers@3.2.5: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:
vue: 3.5.13(typescript@5.9.3)
@@ -17655,17 +17651,15 @@ snapshots:
async: 3.2.5
jszip: 3.10.1
zod-to-json-schema@3.24.1(zod@3.24.1):
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 3.24.1
zod: 4.3.6
zod-validation-error@3.3.0(zod@3.24.1):
zod-validation-error@5.0.0(zod@4.3.6):
dependencies:
zod: 3.24.1
zod: 4.3.6
zod@3.24.1: {}
zod@4.2.1: {}
zod@4.3.6: {}
zustand@5.0.8(@types/react@19.1.9)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
optionalDependencies:

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
@@ -93,6 +91,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
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
@@ -107,9 +106,9 @@ catalog:
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
zod: ^3.23.8
zod-to-json-schema: ^3.24.1
zod-validation-error: ^3.3.0
zod: ^4.3.6
zod-to-json-schema: ^3.25.1
zod-validation-error: ^5.0.0
cleanupUnusedCatalogs: true

View File

@@ -1,22 +1,16 @@
<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 { useForm } from 'vee-validate'
import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
@@ -31,15 +25,23 @@ const { onSuccess } = defineProps<{
onSuccess: () => void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
const { handleSubmit } = useForm({
initialValues: {
confirmPassword: '',
password: ''
},
validationSchema: 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,14 @@
{{ 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 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 +114,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: 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,124 @@
<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">
<FormDescription
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>
</FormDescription>
</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 from '@/components/ui/form/FormControl.vue'
import FormDescription from '@/components/ui/form/FormDescription.vue'
import FormField from '@/components/ui/form/FormField.vue'
import FormItem from '@/components/ui/form/FormItem.vue'
import FormLabel from '@/components/ui/form/FormLabel.vue'
import FormMessage from '@/components/ui/form/FormMessage.vue'
// 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)
}))
type PasswordFormValues = {
confirmPassword: string
password: string
}
const { t } = useI18n()
const { errors, values } = useFormContext<PasswordFormValues>()
const passwordChecks = computed(() => {
const password = values.password ?? ''
return {
length: password.length >= 8 && password.length <= 32,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[^A-Za-z0-9]/.test(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'
@@ -79,13 +77,7 @@ describe('SignInForm', () => {
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
stubs: { ProgressSpinner: true }
},
props,
...options
@@ -93,111 +85,68 @@ describe('SignInForm', () => {
}
describe('Forgot Password Link', () => {
function findForgotPasswordButton(wrapper: VueWrapper<ComponentInstance>) {
const btn = wrapper
.findAll('button[type="button"]')
.find((btn) =>
btn.text().includes(enMessages.auth.login.forgotPassword)
)
if (!btn) throw new Error('Forgot password button not found')
return btn
}
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()
})
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
}
await wrapper
.find('#comfy-org-sign-in-email')
.setValue('test@example.com')
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
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()
const forgotBtn = findForgotPasswordButton(wrapper)
await forgotBtn.trigger('click')
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()
})
})
@@ -205,29 +154,19 @@ describe('SignInForm', () => {
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
const wrapper = mountComponent()
await nextTick()
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(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')
expect(wrapper.html()).not.toContain('<button')
}
expect(wrapper.findComponent(ProgressSpinner).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.findComponent(Button).exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
})
})
@@ -238,7 +177,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 +184,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 +195,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 +218,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 +230,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,68 @@
<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>
<Button
type="button"
variant="textonly"
size="unset"
class="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') }}
</Button>
</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 +70,25 @@
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 { 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/FormField.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
@@ -103,9 +107,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: 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,27 @@
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 { 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 from '@/components/ui/form/FormControl.vue'
import FormField from '@/components/ui/form/FormField.vue'
import FormItem from '@/components/ui/form/FormItem.vue'
import FormLabel from '@/components/ui/form/FormLabel.vue'
import FormMessage from '@/components/ui/form/FormMessage.vue'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -66,9 +63,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: 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,22 @@
<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-foreground', 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,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,48 @@
import { useFieldError } from 'vee-validate'
import { computed, inject } from 'vue'
import {
FORM_FIELD_NAME_INJECTION_KEY,
FORM_ITEM_ID_INJECTION_KEY
} from './injectionKeys'
/**
* Exposes form field identifiers and validation state for a form field component.
*
* @returns An object with:
* - `errorMessage`: a reactive validation message for the injected field name
* - `formDescriptionId`: the element id for the field description (`<itemId>-form-item-description`)
* - `formItemId`: the element id for the form item container (`<itemId>-form-item`)
* - `formMessageId`: the element id for the field validation message (`<itemId>-form-item-message`)
* - `describedBy`: a computed string listing ids to use for `aria-describedby` (includes the message id when an error exists)
* - `name`: the injected field name
*
* @throws Error if the required injection keys (field name or item id) are not found
*/
export function 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

@@ -78,7 +78,7 @@ export const useBrowserTabTitle = () => {
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
(n: { id: string | number; type?: string }) => String(n.id) === nodeId
)?.type || 'Node'
return `${executionText.value}[${progress}%] ${nodeType}`

View File

@@ -14,8 +14,8 @@ const zAsset = z.object({
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
metadata: z.record(z.string(), z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.string(), z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = z.object({

View File

@@ -34,11 +34,13 @@ interface CreateAssetWidgetParams {
}
/**
* Creates an asset widget that opens the Asset Browser dialog for model selection.
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
* Build and attach an asset-selection widget to a LiteGraph node that opens the Asset Browser for choosing an asset.
*
* @param params - Configuration for the asset widget
* @returns The created asset widget
* The widget validates the selected asset and its filename, updates the widget value on success, and triggers the optional
* onValueChange callback. Validation failures are surfaced via console errors and toast notifications.
*
* @param params - Configuration for the asset widget (see CreateAssetWidgetParams)
* @returns The created asset widget attached to the provided node
*/
export function createAssetWidget(
params: CreateAssetWidgetParams
@@ -55,6 +57,13 @@ export function createAssetWidget(
const displayLabel = defaultValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
/**
* Opens the Asset Browser, validates the selected asset and its filename, and updates the provided widget with the validated filename.
*
* If the selected asset or its filename fails validation, logs a descriptive error and displays an error toast; on success the widget's value is set to the validated filename and the optional `onValueChange` callback is invoked with the widget, new value, and old value.
*
* @param widget - The IBaseWidget instance whose value will be updated and which will be passed to the `onValueChange` callback
*/
async function openModal(widget: IBaseWidget) {
const toastStore = useToastStore()
@@ -85,7 +94,7 @@ export function createAssetWidget(
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
validatedFilename.error.issues,
'for asset:',
validatedAsset.data.id
)
@@ -111,4 +120,4 @@ export function createAssetWidget(
}
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
}
}

View File

@@ -53,7 +53,7 @@ export function createModelNodeFromAsset(
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
const errorMessage = validatedAsset.error.errors
const errorMessage = validatedAsset.error.issues
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')
console.error('Invalid asset item:', errorMessage)

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,19 @@
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 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 +99,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: signInSchema
})
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
const onSubmit = handleSubmit((submittedValues) => {
emit('submit', submittedValues)
})
</script>
<style scoped>
:deep(.p-inputtext) {

View File

@@ -23,7 +23,7 @@ const zDownloadFileResult = z.object({
hash: z.string().optional(),
filename: z.string().optional(),
asset_id: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
error: z.string().optional()
})
@@ -31,7 +31,7 @@ const zTaskResponse = z.object({
id: z.string().uuid(),
idempotency_key: z.string(),
task_name: z.string(),
payload: z.record(z.unknown()),
payload: z.record(z.string(), z.unknown()),
status: zTaskStatus,
result: zDownloadFileResult.optional(),
error_message: z.string().optional(),

View File

@@ -69,12 +69,14 @@ describe('parseComfyWorkflow', () => {
// Should automatically transform the legacy format object to array.
workflow.nodes[0].pos = { '0': 3, '1': 4 }
let validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow).not.toBeNull()
if (!validatedWorkflow) return
expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4])
workflow.nodes[0].pos = { 0: 3, 1: 4 }
validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow).not.toBeNull()
if (!validatedWorkflow) return
expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4])
// Should accept the legacy bugged format object.
@@ -92,7 +94,8 @@ describe('parseComfyWorkflow', () => {
'9': 0
}
validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow).not.toBeNull()
if (!validatedWorkflow) return
expect(validatedWorkflow.nodes[0].pos).toEqual([600, 340])
})
@@ -111,7 +114,8 @@ describe('parseComfyWorkflow', () => {
// dynamic widgets display.
workflow.nodes[0].widgets_values = { foo: 'bar' }
const validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow).not.toBeNull()
if (!validatedWorkflow) return
expect(validatedWorkflow.nodes[0].widgets_values).toEqual({ foo: 'bar' })
})

View File

@@ -1,5 +1,4 @@
import { z } from 'zod'
import type { SafeParseReturnType } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { RendererType } from '@/lib/litegraph/src/LGraph'
@@ -198,7 +197,7 @@ const zProperties = z
})
.passthrough()
const zWidgetValues = z.union([z.array(z.any()), z.record(z.any())])
const zWidgetValues = z.union([z.array(z.any()), z.record(z.string(), z.any())])
const zComfyNode = z
.object({
@@ -346,6 +345,12 @@ interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
}
}
// eslint-disable-next-line prefer-const -- Forward declaration required for recursive schema.
let zSubgraphDefinition: z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>
/** Schema version 1 */
export const zComfyWorkflow1 = zBaseExportableGraph
.extend({
@@ -363,16 +368,7 @@ export const zComfyWorkflow1 = zBaseExportableGraph
models: z.array(zModelFile).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => z.array(zSubgraphDefinition)
)
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
})
.optional()
})
@@ -426,7 +422,7 @@ interface SubgraphDefinitionBase<
}
/** A subgraph definition `worfklow.definitions.subgraphs` */
const zSubgraphDefinition = zComfyWorkflow1
zSubgraphDefinition = zComfyWorkflow1
.extend({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid(),
@@ -444,16 +440,7 @@ const zSubgraphDefinition = zComfyWorkflow1
widgets: z.array(zExposedWidget).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => zSubgraphDefinition.array()
)
subgraphs: z.lazy(() => zSubgraphDefinition.array())
})
.optional()
})
@@ -467,7 +454,12 @@ export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow>
export type ComfyWorkflowJSON = z.infer<
typeof zComfyWorkflow | typeof zComfyWorkflow1
>
type SubgraphDefinition = z.infer<typeof zSubgraphDefinition>
type SubgraphDefinition = z.infer<typeof zComfyWorkflow1> & {
id: string
name: string
inputNode: unknown
outputNode: unknown
}
/**
* Type guard to check if an object is a SubgraphDefinition.
@@ -490,13 +482,22 @@ const zWorkflowVersion = z.object({
version: z.number()
})
/**
* Validate and normalize a Comfy workflow payload.
*
* @param data - The workflow data to validate.
* @param onError - Callback invoked with a human-readable validation error message when validation fails.
* @returns The parsed ComfyWorkflowJSON when validation succeeds, `null` when validation fails or the payload lacks a valid version.
*/
export async function validateComfyWorkflow(
data: unknown,
onError: (error: string) => void = console.warn
): Promise<ComfyWorkflowJSON | null> {
const versionResult = zWorkflowVersion.safeParse(data)
let result: SafeParseReturnType<unknown, ComfyWorkflowJSON>
let result:
| { success: true; data: ComfyWorkflowJSON }
| { success: false; error: z.ZodError }
if (!versionResult.success) {
// Invalid workflow
const error = fromZodError(versionResult.error)
@@ -536,4 +537,4 @@ const zNodeData = z.object({
})
const zComfyApiWorkflow = z.record(zNodeId, zNodeData)
export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>
export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>

View File

@@ -259,7 +259,7 @@ const zUser = z.object({
// `users` is only available in multi-user server mode.
users: z.record(z.string(), z.string()).optional()
})
const zUserData = z.array(z.array(z.string(), z.string()))
const zUserData = z.array(z.tuple([z.string(), z.string()]))
const zUserDataFullInfo = z.object({
path: z.string(),
size: z.number(),
@@ -310,7 +310,7 @@ const zSettings = z.object({
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
'Comfy.Graph.DeduplicateSubgraphNodeIds': z.boolean(),
'Comfy.Graph.LiveSelection': z.boolean(),
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
'Comfy.Graph.LinkMarkers': z.enum(LinkMarkerShape),
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
'Comfy.GroupSelectedNodes.Padding': z.number(),

View File

@@ -116,7 +116,7 @@ const completedPaletteSchema = z
})
.passthrough()
export const colorPalettesSchema = z.record(paletteSchema)
export const colorPalettesSchema = z.record(z.string(), paletteSchema)
export type Colors = z.infer<typeof colorsSchema>
export type Palette = z.infer<typeof paletteSchema>

View File

@@ -54,14 +54,14 @@ const zImageInputSpec = zBaseInputOptions.extend({
type: z.literal('IMAGE'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
options: z.record(z.string(), z.unknown()).optional()
})
const zImageCompareInputSpec = zBaseInputOptions.extend({
type: z.literal('IMAGECOMPARE'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
options: z.record(z.string(), z.unknown()).optional()
})
const zBoundingBoxInputSpec = zBaseInputOptions.extend({
@@ -161,9 +161,9 @@ const zOutputSpec = z.object({
// Main node definition schema
export const zComfyNodeDef = z.object({
inputs: z.record(zInputSpec),
inputs: z.record(z.string(), zInputSpec),
outputs: z.array(zOutputSpec),
hidden: z.record(z.any()).optional(),
hidden: z.record(z.string(), z.any()).optional(),
name: z.string(),
display_name: z.string(),

View File

@@ -199,11 +199,11 @@ const zInputSpec = z.union([
])
const zComfyInputsSpec = z.object({
required: z.record(zInputSpec).optional(),
optional: z.record(zInputSpec).optional(),
required: z.record(z.string(), zInputSpec).optional(),
optional: z.record(z.string(), zInputSpec).optional(),
// Frontend repo is not using it, but some custom nodes are using the
// hidden field to pass various values.
hidden: z.record(z.any()).optional()
hidden: z.record(z.string(), z.any()).optional()
})
const zComfyNodeDataType = z.string()
@@ -285,7 +285,7 @@ export const zComfyNodeDef = z.object({
* Used to ensure consistent widget ordering regardless of JSON serialization.
* Keys are 'required', 'optional', etc., values are arrays of input names.
*/
input_order: z.record(z.array(z.string())).optional(),
input_order: z.record(z.string(), z.array(z.string())).optional(),
/**
* Alternative names for search. Useful for synonyms, abbreviations,
* or old names after renaming a node.

View File

@@ -1224,7 +1224,7 @@ export class ComfyApp {
}
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
// @ts-expect-error Zod schema type allows broader slot types than LiteGraph configure typings.
this.rootGraph.configure(graphData)
// Save original renderer version before scaling (it gets modified during scaling)

View File

@@ -8,7 +8,10 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyNode,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -425,6 +428,12 @@ export class ChangeTracker {
}
private static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
/**
* Create a copy of a workflow object with its `nodes` array ordered by numeric node id.
*
* @param graph - The workflow JSON to normalize
* @returns A new workflow-like object containing the same top-level properties as `graph` but with `nodes` sorted in ascending order when node `id`s are numbers
*/
function sortGraphNodes(graph: ComfyWorkflowJSON) {
return {
links: graph.links,
@@ -434,7 +443,7 @@ export class ChangeTracker {
extra: graph.extra,
definitions: graph.definitions,
subgraphs: graph.subgraphs,
nodes: graph.nodes.sort((a, b) => {
nodes: graph.nodes.sort((a: ComfyNode, b: ComfyNode) => {
if (typeof a.id === 'number' && typeof b.id === 'number') {
return a.id - b.id
}
@@ -444,4 +453,4 @@ export class ChangeTracker {
}
return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b))
}
}
}

View File

@@ -209,8 +209,9 @@ export const useExecutionStore = defineStore('execution', () => {
if (!canvasState) return null
return (
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
null
canvasState.nodes.find(
(n: { id: string | number }) => String(n.id) === executingNodeId.value
) ?? null
)
})

View File

@@ -60,9 +60,14 @@ export const useSubgraphStore = defineStore('subgraph', () => {
)
const { subgraphs } = this.activeState.definitions
const { nodes } = this.activeState
//Instanceof doesn't function as nodes are serialized
/**
* Determines whether a given node represents a subgraph node.
*
* @param node - The node to test
* @returns `true` if the node corresponds to a registered subgraph type, `false` otherwise.
*/
function isSubgraphNode(node: ComfyNode) {
return node && subgraphs.some((s) => s.id === node.type)
return node && subgraphs.some((s: { id: string }) => s.id === node.type)
}
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
const errors: Record<NodeId, NodeError> = {}
@@ -146,7 +151,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const loaded = await super.load({ force })
const st = loaded.activeState
const sg = (st.definitions?.subgraphs ?? []).find(
(sg) => sg.id == st.nodes[0].type
(sg: { id: string }) => sg.id == st.nodes[0].type
)
if (!sg)
throw new Error(
@@ -185,6 +190,11 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const subgraphBlueprints = computed(() => [
...subgraphDefCache.value.values()
])
/**
* Loads user and installed subgraph blueprints, registers their node definitions, and attaches them to the workflow store.
*
* Loads blueprints from the user's SubgraphBlueprints folder and from the platform-provided (global) set, filters installed blueprints by distribution and custom-node requirements, registers resulting node definitions, and attaches the corresponding workflows to the workflow store. Any load failures are logged to the console and surfaced to the user as an error toast summarizing the number or list of failures.
*/
async function fetchSubgraphs() {
async function loadBlueprint(options: {
path: string
@@ -249,7 +259,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
loadInstalledBlueprints()
])
const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason)
const errors = settled
.filter((i): i is PromiseRejectedResult => i.status === 'rejected')
.map((i) => i.reason)
errors.forEach((e) => console.error('Failed to load subgraph blueprint', e))
if (errors.length > 0) {
useToastStore().add({
@@ -260,6 +272,18 @@ export const useSubgraphStore = defineStore('subgraph', () => {
})
}
}
/**
* Build and register a Comfy node definition for a loaded subgraph blueprint.
*
* Creates a ComfyNodeDefV1 from the blueprint's root subgraph node, applies any provided overrides,
* stores the resulting node definition in the subgraph definition cache, and associates the
* loaded workflow in the subgraph workflow cache under the given name.
*
* @param workflow - The loaded subgraph workflow to register as a node definition
* @param overrides - Partial node definition fields to apply on top of the generated definition
* @param name - The blueprint name to register (defaults to the workflow filename)
* @throws Error if the workflow does not contain a root subgraph node suitable for registration
*/
function registerNodeDef(
workflow: LoadedComfyWorkflow,
overrides: Partial<ComfyNodeDefV1> = {},
@@ -271,10 +295,12 @@ export const useSubgraphStore = defineStore('subgraph', () => {
subgraphNode.outputs ??= []
//NOTE: Types are cast to string. This is only used for input coloring on previews
const inputs = Object.fromEntries(
subgraphNode.inputs.map((i) => [
i.name,
[`${i.type}`, undefined] satisfies InputSpec
])
subgraphNode.inputs.map(
(i: { name: string; type: string | number | string[] }) => [
i.name,
[`${i.type}`, undefined] satisfies InputSpec
]
)
)
const workflowExtra = workflow.initialState.extra
const description =
@@ -282,8 +308,10 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const search_aliases = workflowExtra?.BlueprintSearchAliases
const nodedefv1: ComfyNodeDefV1 = {
input: { required: inputs },
output: subgraphNode.outputs.map((o) => `${o.type}`),
output_name: subgraphNode.outputs.map((o) => o.name),
output: subgraphNode.outputs.map(
(o: { type: string | number | string[] }) => `${o.type}`
),
output_name: subgraphNode.outputs.map((o: { name: string }) => o.name),
name: typePrefix + name,
display_name: name,
description,
@@ -424,4 +452,4 @@ export const useSubgraphStore = defineStore('subgraph', () => {
subgraphBlueprints,
typePrefix
}
})
})

View File

@@ -61,8 +61,8 @@ const mergeNumericInputSpec = <T extends IntInputSpec | FloatInputSpec>(
}
return mergeCommonInputSpec(
[type, { ...options1, ...mergedOptions }] as T,
[type, { ...options2, ...mergedOptions }] as T
[type, { ...options1, ...mergedOptions }] as unknown as T,
[type, { ...options2, ...mergedOptions }] as unknown as T
)
}
@@ -84,8 +84,8 @@ const mergeComboInputSpec = <T extends ComboInputSpec | ComboInputSpecV2>(
}
return mergeCommonInputSpec(
['COMBO', { ...options1, options: intersection }] as T,
['COMBO', { ...options2, options: intersection }] as T
['COMBO', { ...options1, options: intersection }] as unknown as T,
['COMBO', { ...options2, options: intersection }] as unknown as T
)
}
@@ -107,7 +107,9 @@ const mergeCommonInputSpec = <T extends InputSpec>(
return value1 === value2 || (_.isNil(value1) && _.isNil(value2))
})
return mergeIsValid ? ([type, { ...options1, ...options2 }] as T) : null
return mergeIsValid
? ([type, { ...options1, ...options2 }] as unknown as T)
: null
}
/**