Compare commits

..

20 Commits

Author SHA1 Message Date
Comfy Org PR Bot
dcce26e536 1.39.16 (#9161)
Patch version increment to 1.39.16

**Base branch:** `core/1.39`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9161-1-39-16-3116d73d36508126878def486b3de1cf)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-23 22:03:24 -08:00
Comfy Org PR Bot
dbeb090975 [backport core/1.39] fix: use getAuthHeader for API key auth in subscription/billing (#9160)
Backport of #9142 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9160-backport-core-1-39-fix-use-getAuthHeader-for-API-key-auth-in-subscription-billing-3116d73d365081979e17cc176087b7fb)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-23 21:45:36 -08:00
Comfy Org PR Bot
0b7c666b5f 1.39.15 (#9157)
Patch version increment to 1.39.15

**Base branch:** `core/1.39`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9157-1-39-15-3116d73d3650813a9e71fe7f6d667065)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-23 21:16:35 -08:00
Comfy Org PR Bot
9fcf58298e [backport core/1.39] fix: promoted widget labels show widgetName instead of "nodeId: widgetName" (#9040)
Backport of #9013 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9040-backport-core-1-39-fix-promoted-widget-labels-show-widgetName-instead-of-nodeId-widg-30e6d73d3650814a96e3f4edb9cd7079)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-20 19:25:02 -08:00
Comfy Org PR Bot
36fae34a58 [backport core/1.39] [bugfix] Fix node replacements not loading due to feature flag timing (#9043)
Backport of #9037 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9043-backport-core-1-39-bugfix-Fix-node-replacements-not-loading-due-to-feature-flag-timin-30e6d73d3650810e9a9ee1a049bb913e)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-02-20 19:24:51 -08:00
Comfy Org PR Bot
7d1619823c [backport core/1.39] fix: restore mouse-wheel scrolling in preview-as-text outputs (#9006)
Backport of #8863 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9006-backport-core-1-39-fix-restore-mouse-wheel-scrolling-in-preview-as-text-outputs-30d6d73d365081bf9398c56dece63fe9)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-20 02:38:12 -08:00
Comfy Org PR Bot
87c2bcc408 [backport core/1.39] fix: use getAuthHeader in createCustomer for API key auth support (#8984)
Backport of #8983 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8984-backport-core-1-39-fix-use-getAuthHeader-in-createCustomer-for-API-key-auth-support-30c6d73d365081b88e1bffa5a9e6f469)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-19 02:38:40 -08:00
Comfy Org PR Bot
c8537e45bf [backport core/1.39] fix: set audio widget value after file upload (#8958)
Backport of #8814 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8958-backport-core-1-39-fix-set-audio-widget-value-after-file-upload-30b6d73d36508161a12fed45c4cdca00)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-18 04:08:06 -08:00
Comfy Org PR Bot
a0b1fcd232 [backport core/1.39] fix: resolve ImageCrop input image through subgraph nodes (#8946)
Backport of #8899 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8946-backport-core-1-39-fix-resolve-ImageCrop-input-image-through-subgraph-nodes-30b6d73d36508154a57cd063bb68a174)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-18 02:24:01 -08:00
Comfy Org PR Bot
241b98b46c [backport core/1.39] feat: gate node replacement loading on server feature flag (#8940)
Backport of #8750 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8940-backport-core-1-39-feat-gate-node-replacement-loading-on-server-feature-flag-30a6d73d365081f48ddefa075b8bbf7d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 15:56:59 -08:00
Comfy Org PR Bot
5fcbc55879 1.39.14 (#8942)
Patch version increment to 1.39.14

**Base branch:** `core/1.39`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8942-1-39-14-30a6d73d365081b3a8baecef69b6fc32)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-17 12:41:42 -08:00
Comfy Org PR Bot
6649906293 [backport core/1.39] [feat] Node replacement UI (#8933)
Backport of #8604 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8933-backport-core-1-39-feat-Node-replacement-UI-30a6d73d36508128a5a6f0d9aa006f13)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 10:56:33 -08:00
Comfy Org PR Bot
725b45c20c [backport core/1.39] fix: prevent XSS vulnerability in context menu labels (#8924)
Backport of #8887 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8924-backport-core-1-39-fix-prevent-XSS-vulnerability-in-context-menu-labels-3096d73d365081a59774ca63128b6b20)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 02:23:37 -08:00
Comfy Org PR Bot
bbae1a0c6e [backport core/1.39] feat: classify missing nodes by replacement availability (#8931)
Backport of #8483 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8931-backport-core-1-39-feat-classify-missing-nodes-by-replacement-availability-30a6d73d365081cbb1cdef5d62b4687d)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-02-16 23:45:47 -08:00
Comfy Org PR Bot
0b952a8b21 1.39.13 (#8916)
Patch version increment to 1.39.13

**Base branch:** `core/1.39`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8916-1-39-13-3096d73d3650815599b5f1890498a9e8)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-02-16 22:01:49 -08:00
Comfy Org PR Bot
9f760b543b [backport core/1.39] fix: SaveImage node not updating outputs during batch runs (vue-nodes) (#8893)
Backport of #8862 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8893-backport-core-1-39-fix-SaveImage-node-not-updating-outputs-during-batch-runs-vue-node-3086d73d36508181a887e26121d3e590)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-15 13:38:57 +00:00
Comfy Org PR Bot
805649e2c9 [backport core/1.39] fix: hide output images for ImageCropV2 node (#8883)
Backport of #8873 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8883-backport-core-1-39-fix-hide-output-images-for-ImageCropV2-node-3076d73d3650810da523e02735cedab9)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-15 05:01:49 -08:00
Comfy Org PR Bot
fc627106e1 [backport core/1.39] feat: add hideOutputImages flag for nodes with custom preview (#8891)
Backport of #8857 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8891-backport-core-1-39-feat-add-hideOutputImages-flag-for-nodes-with-custom-preview-3086d73d365081e292d1d363d019eb38)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-15 04:50:50 -08:00
Comfy Org PR Bot
3b88f55158 [backport core/1.39] fix: clear draft on workflow close to prevent stale state on reopen (#8870)
Backport of #8854 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8870-backport-core-1-39-fix-clear-draft-on-workflow-close-to-prevent-stale-state-on-reopen-3076d73d365081448562eccd00a790b0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-14 02:59:05 -08:00
Comfy Org PR Bot
8cfc714e9f [backport core/1.39] Add z-index to popover component (#8824)
Backport of #8823 to `core/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8824-backport-core-1-39-Add-z-index-to-popover-component-3056d73d3650818da234dfc0095e612c)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-11 21:51:44 -08:00
712 changed files with 6840 additions and 6173 deletions

View File

@@ -21,7 +21,6 @@
"eslint",
"import",
"oxc",
"promise",
"typescript",
"unicorn",
"vitest",
@@ -29,12 +28,6 @@
],
"rules": {
"no-async-promise-executor": "off",
"no-else-return": [
"error",
{
"allowElseIf": false
}
],
"no-console": [
"error",
{
@@ -42,29 +35,8 @@
}
],
"no-control-regex": "off",
"eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"func-style": [
"error",
"declaration",
{
"allowArrowFunctions": true
}
],
"no-eval": "off",
"no-new-func": "error",
// TODO: Enable and fix 104 violations
"no-param-reassign": "off",
"no-redeclare": "error",
"no-return-assign": ["error", "always"],
"no-throw-literal": "error",
"no-useless-constructor": "error",
"no-var": "error",
"no-restricted-imports": [
"error",
{
@@ -92,66 +64,15 @@
]
}
],
"no-unneeded-ternary": [
"error",
{
"defaultAssignment": false
}
],
"no-useless-call": "error",
"no-useless-concat": "error",
"prefer-const": "error",
// TODO: Enable and fix 581 violations
"prefer-destructuring": "off",
"prefer-object-has-own": "error",
"prefer-object-spread": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"promise/no-nesting": "error",
"promise/param-names": "error",
// TODO: Enable and fix 76 violations
"promise/prefer-await-to-callbacks": "off",
// TODO: Enable and fix 91 violations
"promise/prefer-await-to-then": "off",
"promise/prefer-catch": "error",
"preserve-caught-error": "error",
"yoda": [
"error",
"never",
{
"exceptRange": true
}
],
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"operator-assignment": ["error", "always"],
"import/default": "error",
"import/export": "error",
"import/first": ["error", "absolute-first"],
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"describe": "for"
}
],
"vitest/consistent-test-filename": [
"error",
{
"pattern": ".*\\.test\\.ts$"
}
],
"vitest/consistent-vitest-vi": "error",
"vitest/warn-todo": "warn",
"vitest/hoisted-apis-on-top": "error",
"vitest/no-conditional-tests": "error",
"vitest/prefer-describe-function-title": "error",
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
@@ -161,55 +82,11 @@
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/catch-error-name": [
"error",
{
"ignore": ["^error\\w+$"]
}
],
// TODO: Enable and fix 147 violations
"unicorn/consistent-function-scoping": "off",
"unicorn/error-message": "error",
"unicorn/no-abusive-eslint-disable": "error",
// TODO: Enable and fix 165 violations
"unicorn/no-array-for-each": "off",
"unicorn/no-immediate-mutation": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-lonely-if": "error",
"unicorn/no-negation-in-equality-check": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-array-flat-map": "error",
"unicorn/no-empty-file": "off",
"unicorn/no-new-array": "off",
"unicorn/prefer-add-event-listener": "error",
"unicorn/prefer-array-find": "error",
"unicorn/no-useless-undefined": [
"error",
{
"checkArguments": false,
"checkArrowFunctionBody": false
}
],
"unicorn/prefer-classlist-toggle": "error",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-this-assignment": "error",
"unicorn/no-useless-collection-argument": "error",
"unicorn/no-useless-switch-case": "error",
"unicorn/no-useless-fallback-in-spread": "off",
"unicorn/no-useless-spread": "off",
"unicorn/prefer-optional-catch-binding": "error",
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-query-selector": "error",
"unicorn/prefer-spread": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-has": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/prefer-string-trim-start-end": "error",
"unicorn/prefer-type-error": "error",
"unicorn/throw-new-error": "error",
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-duplicate-type-constituents": "off",
@@ -219,12 +96,6 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
// TODO: Enable and fix 372 violations (use { "ignoreConditionalTests": true })
"typescript/prefer-nullish-coalescing": "off",
// TODO: Enable and fix violations
"typescript/prefer-optional-chain": "off",
"typescript/prefer-ts-expect-error": "error",
"vue/define-props-destructuring": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
@@ -243,8 +114,7 @@
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error",
"vitest/consistent-test-filename": "off"
"unicorn/no-empty-file": "error"
}
}
]

View File

@@ -98,10 +98,12 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true,
strictExecutionOrder: true
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -0,0 +1,205 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,186 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -5,6 +5,13 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Graph.DeduplicateSubgraphNodeIds',
true
)
})
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {

View File

@@ -61,10 +61,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -80,10 +77,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -12,18 +12,6 @@ This guide covers patterns and examples for testing Vue components in the ComfyU
6. [Asynchronous Component Testing](#asynchronous-component-testing)
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
## Describe Block Naming
Use `Component.__name ?? 'ComponentName'` for the top-level `describe` title. This passes the function reference (satisfying the `prefer-describe-function-title` lint rule) while providing a readable fallback:
```typescript
import MyComponent from './MyComponent.vue'
describe(MyComponent.__name ?? 'MyComponent', () => {
// ...
})
```
## Basic Component Testing
Basic approach to testing a component's rendering and structure:
@@ -33,7 +21,7 @@ Basic approach to testing a component's rendering and structure:
import { mount } from '@vue/test-utils'
import SidebarIcon from './SidebarIcon.vue'
describe(SidebarIcon.__name ?? 'SidebarIcon', () => {
describe('SidebarIcon', () => {
const exampleProps = {
icon: 'pi pi-cog',
selected: false

View File

@@ -138,10 +138,6 @@ export default defineConfig([
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
'vue/return-in-computed-property': [
'error',
{ treatUndefinedAsUnspecified: false }
],
'vue/no-v-html': 'off',
// Prohibit dark-theme: and dark: prefixes
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.40.0",
"version": "1.39.16",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -193,7 +193,7 @@
},
"pnpm": {
"overrides": {
"vite": "catalog:"
"vite": "^8.0.0-beta.8"
}
}
}

1103
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -69,9 +69,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.31.0
oxlint: ^1.46.0
oxlint-tsgolint: ^0.11.5
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
@@ -92,7 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: 8.0.0-beta.13
vite: ^8.0.0-beta.8
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -33,7 +33,6 @@ fi
EXCLUDE_PATTERNS=(
'**/tsconfig*.json'
'.oxlintrc.json'
)
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then

View File

@@ -16,12 +16,12 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()

View File

@@ -49,7 +49,7 @@ describe('downloadUtil', () => {
vi.unstubAllGlobals()
})
describe(downloadFile, () => {
describe('downloadFile', () => {
it('should create and trigger download with basic URL', () => {
const testUrl = 'https://example.com/image.png'
@@ -285,7 +285,7 @@ describe('downloadUtil', () => {
})
})
describe(extractFilenameFromContentDisposition, () => {
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})

View File

@@ -172,17 +172,19 @@ const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() =>
sidebarLocation.value === 'left'
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
: undefined
)
const firstPanelStyle = computed(() => {
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const lastPanelStyle = computed(() =>
sidebarLocation.value === 'right'
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
: undefined
)
const lastPanelStyle = computed(() => {
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
</script>
<style scoped>

View File

@@ -108,7 +108,7 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe(TopMenuSection.__name ?? 'TopMenuSection', () => {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
@@ -242,7 +242,7 @@ describe(TopMenuSection.__name ?? 'TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return
return undefined
})
}

View File

@@ -287,8 +287,9 @@ const openCustomNodeManager = async () => {
} catch (error) {
try {
toastErrorHandler(error)
} catch (error) {
} catch (toastError) {
console.error(error)
console.error(toastError)
}
}
}

View File

@@ -54,7 +54,7 @@ vi.mock('primevue/progressspinner', () => ({
default: { template: '<div class="progress-spinner" />' }
}))
describe(WorkspaceAuthGate.__name ?? 'WorkspaceAuthGate', () => {
describe('WorkspaceAuthGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true

View File

@@ -51,7 +51,7 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
describe(EssentialsPanel.__name ?? 'EssentialsPanel', () => {
describe('EssentialsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})

View File

@@ -23,7 +23,7 @@ vi.mock('vue-i18n', () => ({
})
}))
describe(ShortcutsList.__name ?? 'ShortcutsList', () => {
describe('ShortcutsList', () => {
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',

View File

@@ -106,7 +106,7 @@ const mountBaseTerminal = () => {
})
}
describe(BaseTerminal.__name ?? 'BaseTerminal', () => {
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {

View File

@@ -62,8 +62,8 @@ const terminalCreated = (
onMounted(async () => {
try {
await loadLogEntries()
} catch (error) {
console.error('Error loading logs', error)
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'

View File

@@ -78,7 +78,9 @@ interface Props {
isActive?: boolean
}
const { item, isActive = false } = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -101,7 +103,7 @@ const rename = async (
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
item.updateTitle?.(newName)
props.item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
@@ -125,13 +127,13 @@ const rename = async (
}
}
const isRoot = item.key === 'root'
const isRoot = props.item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return item.label
return props.item.label
})
const startRename = async () => {
@@ -143,7 +145,7 @@ const startRename = async () => {
}
isEditing.value = true
itemLabel.value = item.label as string
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
@@ -163,12 +165,12 @@ const handleClick = (event: MouseEvent) => {
}
if (event.detail === 1) {
if (isActive) {
if (props.isActive) {
menu.value?.toggle(event)
} else {
item.command?.({ item, originalEvent: event })
props.item.command?.({ item: props.item, originalEvent: event })
}
} else if (isActive && event.detail === 2) {
} else if (props.isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
@@ -178,7 +180,7 @@ const handleClick = (event: MouseEvent) => {
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, item.label as string)
await rename(itemLabel.value, props.item.label as string)
}
isEditing.value = false

View File

@@ -7,128 +7,123 @@ import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe(
ColorCustomizationSelector.__name ?? 'ColorCustomizationSelector',
() => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
describe('ColorCustomizationSelector', () => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
})
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(
colorOptions.length + 1
)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
})
}
)
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
})
})

View File

@@ -5,7 +5,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const { renderFunction } = defineProps<{
const props = defineProps<{
renderFunction: () => HTMLElement
}>()
@@ -14,12 +14,12 @@ const container = ref<HTMLElement | null>(null)
function renderContent() {
if (container.value) {
container.value.innerHTML = ''
const element = renderFunction()
const element = props.renderFunction()
container.value.appendChild(element)
}
}
onMounted(renderContent)
watch(() => renderFunction, renderContent)
watch(() => props.renderFunction, renderContent)
</script>

View File

@@ -52,7 +52,7 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const { t } = useI18n()
const { modelValue, initialIcon, initialColor } = defineProps<{
const props = defineProps<{
modelValue: boolean
initialIcon?: string
initialColor?: string
@@ -64,7 +64,7 @@ const emit = defineEmits<{
}>()
const visible = computed({
get: () => modelValue,
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
@@ -96,13 +96,17 @@ const defaultIcon = iconOptions.find(
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === initialIcon) || defaultIcon
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
}
const confirmCustomization = () => {
@@ -115,7 +119,7 @@ const closeDialog = () => {
}
watch(
() => modelValue,
() => props.modelValue,
(newValue: boolean) => {
if (newValue) {
resetCustomization()

View File

@@ -5,7 +5,7 @@
{{ col.header }}
</div>
<div>
{{ formatValue(device[col.field], col.field) }}
{{ formatValue(props.device[col.field], col.field) }}
</div>
</template>
</div>
@@ -15,7 +15,7 @@
import type { DeviceStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const { device } = defineProps<{
const props = defineProps<{
device: DeviceStats
}>()

View File

@@ -6,7 +6,7 @@ import { createApp } from 'vue'
import EditableText from './EditableText.vue'
describe(EditableText.__name ?? 'EditableText', () => {
describe('EditableText', () => {
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})

View File

@@ -5,10 +5,10 @@
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="displayHint">{{ displayLabel }}</span>
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="error" class="file-error">
{{ error }}
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
@@ -18,14 +18,14 @@
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!error"
:disabled="!!props.error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!url"
v-if="(status === null || status === 'error') && !!props.url"
variant="secondary"
size="sm"
@click="copyURL"
@@ -53,7 +53,7 @@
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!error"
:disabled="!!props.error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
@@ -66,7 +66,7 @@
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!error"
:disabled="!!props.error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
@@ -78,7 +78,7 @@
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!error"
:disabled="!!props.error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
@@ -98,7 +98,7 @@ import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const { url, hint, label, error } = defineProps<{
const props = defineProps<{
url: string
hint?: string
label?: string
@@ -106,9 +106,9 @@ const { url, hint, label, error } = defineProps<{
}>()
const { t } = useI18n()
const displayLabel = computed(() => label || url.split('/').pop())
const displayHint = computed(() => hint || url)
const download = useDownload(url)
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
@@ -117,10 +117,10 @@ const fileSize = computed(() =>
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = label.split('/')
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => url === download.url)
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
@@ -132,17 +132,17 @@ electronDownloadStore.$subscribe((_, { downloads }) => {
const triggerDownload = async () => {
await electronDownloadStore.start({
url,
url: props.url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(url)
const triggerPauseDownload = () => electronDownloadStore.pause(url)
const triggerResumeDownload = () => electronDownloadStore.resume(url)
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const copyURL = async () => {
await copyToClipboard(url)
await copyToClipboard(props.url)
}
</script>

View File

@@ -5,7 +5,10 @@
:ref="
(el) => {
if (el)
mountCustomExtension(extension as CustomExtension, el as HTMLElement)
mountCustomExtension(
props.extension as CustomExtension,
el as HTMLElement
)
}
"
/>
@@ -16,17 +19,17 @@ import { onBeforeUnmount } from 'vue'
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
const { extension } = defineProps<{
const props = defineProps<{
extension: VueExtension | CustomExtension
}>()
const mountCustomExtension = (ext: CustomExtension, el: HTMLElement) => {
ext.render(el)
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
extension.render(el)
}
onBeforeUnmount(() => {
if (extension.type === 'custom' && extension.destroy) {
extension.destroy()
if (props.extension.type === 'custom' && props.extension.destroy) {
props.extension.destroy()
}
})
</script>

View File

@@ -3,35 +3,35 @@
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="displayHint">{{ displayLabel }}</span>
<span :title="hint">{{ label }}</span>
</div>
<Message
v-if="error"
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="error"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ error }}
{{ props.error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!error"
:title="url"
:disabled="!!props.error"
:title="props.url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!error" @click="copyURL">
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
@@ -47,22 +47,22 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const { url, hint, label, error } = defineProps<{
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const displayLabel = computed(() => label || url.split('/').pop())
const label = computed(() => props.label || props.url.split('/').pop())
const displayHint = computed(() => hint || url)
const download = useDownload(url)
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(url)
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()

View File

@@ -10,7 +10,7 @@ import ColorPicker from 'primevue/colorpicker'
import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
const { label } = defineProps<{
defineProps<{
label?: string
}>()

View File

@@ -45,7 +45,7 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
const { modelValue } = defineProps<{
defineProps<{
modelValue: string
}>()
@@ -64,9 +64,9 @@ const handleFileUpload = (event: Event) => {
if (target.files && target.files[0]) {
const file = target.files[0]
const reader = new FileReader()
reader.addEventListener('load', (e) => {
reader.onload = (e) => {
emit('update:modelValue', e.target?.result as string)
})
}
reader.readAsDataURL(file)
}
}

View File

@@ -2,12 +2,16 @@
<template>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex grow items-center">
<span :id="`${id}-label`" class="text-muted" :class="labelClass">
<span
:id="`${props.id}-label`"
class="text-muted"
:class="props.labelClass"
>
<slot name="name-prefix" />
{{ item.name }}
{{ props.item.name }}
<i
v-if="item.tooltip"
v-tooltip="item.tooltip"
v-if="props.item.tooltip"
v-tooltip="props.item.tooltip"
class="pi pi-info-circle bg-transparent"
/>
<slot name="name-suffix" />
@@ -15,11 +19,11 @@
</div>
<div class="form-input flex justify-end">
<component
:is="markRaw(getFormComponent(item))"
:id="id"
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:model-value="formValue"
:aria-labelledby="`${id}-label`"
v-bind="getFormAttrs(item)"
:aria-labelledby="`${props.id}-label`"
v-bind="getFormAttrs(props.item)"
/>
</div>
</div>
@@ -44,37 +48,35 @@ import UrlInput from '@/components/common/UrlInput.vue'
import type { FormItem } from '@/platform/settings/types'
const formValue = defineModel<unknown>('formValue')
const { item, id, labelClass } = defineProps<{
const props = defineProps<{
item: FormItem
id?: string
labelClass?: string | Record<string, boolean>
}>()
function getFormAttrs(formItem: FormItem) {
const attrs = { ...(formItem.attrs || {}) }
const inputType = formItem.type
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }
const inputType = item.type
if (typeof inputType === 'function') {
attrs['renderFunction'] = () =>
inputType(
formItem.name,
(v: unknown) => {
formValue.value = v
},
props.item.name,
(v: unknown) => (formValue.value = v),
formValue.value,
formItem.attrs
item.attrs
)
}
switch (formItem.type) {
switch (item.type) {
case 'combo':
case 'radio':
attrs['options'] =
typeof formItem.options === 'function'
typeof item.options === 'function'
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
// (value) => [string | {text: string, value: string}]
formItem.options(formValue.value)
: formItem.options
item.options(formValue.value)
: item.options
if (typeof formItem.options?.[0] !== 'string') {
if (typeof item.options?.[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
@@ -83,11 +85,11 @@ function getFormAttrs(formItem: FormItem) {
return attrs
}
function getFormComponent(formItem: FormItem): Component {
if (typeof formItem.type === 'function') {
function getFormComponent(item: FormItem): Component {
if (typeof item.type === 'function') {
return CustomFormValue
}
switch (formItem.type) {
switch (item.type) {
case 'boolean':
return ToggleSwitch
case 'number':

View File

@@ -5,242 +5,239 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
import type { ComponentProps } from 'vue-component-type-helpers'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe(
(FormRadioGroup as { __name?: string }).__name ?? 'FormRadioGroup',
() => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
describe('FormRadioGroup', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
})
}
)
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
})
})

View File

@@ -26,7 +26,7 @@ import { computed } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
const { modelValue, options, optionLabel, optionValue, id } = defineProps<{
const props = defineProps<{
modelValue: T
options?: (string | SettingOption | Record<string, string>)[]
optionLabel?: string
@@ -39,9 +39,9 @@ defineEmits<{
}>()
const normalizedOptions = computed<SettingOption[]>(() => {
if (!options) return []
if (!props.options) return []
return options.map((option) => {
return props.options.map((option) => {
if (typeof option === 'string') {
return { text: option, value: option }
}
@@ -54,8 +54,8 @@ const normalizedOptions = computed<SettingOption[]>(() => {
}
// Handle optionLabel/optionValue
return {
text: option[optionLabel || 'text'] || 'Unknown',
value: option[optionValue || 'value']
text: option[props.optionLabel || 'text'] || 'Unknown',
value: option[props.optionValue || 'value']
}
})
})

View File

@@ -30,25 +30,24 @@ import InputNumber from 'primevue/inputnumber'
import Knob from 'primevue/knob'
import { ref, watch } from 'vue'
const { modelValue, inputClass, knobClass, min, max, step, resolution } =
defineProps<{
modelValue: number
inputClass?: string
knobClass?: string
min?: number
max?: number
step?: number
resolution?: number
}>()
const props = defineProps<{
modelValue: number
inputClass?: string
knobClass?: string
min?: number
max?: number
step?: number
resolution?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const localValue = ref(modelValue)
const localValue = ref(props.modelValue)
watch(
() => modelValue,
() => props.modelValue,
(newValue) => {
localValue.value = newValue
}
@@ -57,18 +56,18 @@ watch(
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(min) || 0
newValue = Number(props.min) || 0
}
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
const stepVal = Number(step) || 1
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(minVal, Math.min(maxVal, newValue))
newValue = Math.max(min, Math.min(max, newValue))
// Round to the nearest step
newValue = Math.round(newValue / stepVal) * stepVal
newValue = Math.round(newValue / step) * step
// Update local value and emit change
localValue.value = newValue
@@ -77,11 +76,11 @@ const updateValue = (newValue: number | null) => {
const displayValue = (value: number): string => {
updateValue(value)
const stepString = (step ?? 1).toString()
const decimalPlaces = stepString.includes('.')
const stepString = (props.step ?? 1).toString()
const resolution = stepString.includes('.')
? stepString.split('.')[1].length
: 0
return value.toFixed(resolution ?? decimalPlaces)
return value.toFixed(props.resolution ?? resolution)
}
defineOptions({

View File

@@ -29,7 +29,7 @@ import InputNumber from 'primevue/inputnumber'
import Slider from 'primevue/slider'
import { ref, watch } from 'vue'
const { modelValue, inputClass, sliderClass, min, max, step } = defineProps<{
const props = defineProps<{
modelValue: number
inputClass?: string
sliderClass?: string
@@ -42,10 +42,10 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const localValue = ref(modelValue)
const localValue = ref(props.modelValue)
watch(
() => modelValue,
() => props.modelValue,
(newValue) => {
localValue.value = newValue
}
@@ -54,18 +54,18 @@ watch(
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(min) || 0
newValue = Number(props.min) || 0
}
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
const stepVal = Number(step) || 1
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(minVal, Math.min(maxVal, newValue))
newValue = Math.max(min, Math.min(max, newValue))
// Round to the nearest step
newValue = Math.round(newValue / stepVal) * stepVal
newValue = Math.round(newValue / step) * step
// Update local value and emit change
localValue.value = newValue

View File

@@ -41,6 +41,7 @@ const spinnerSizeClass = computed(() => {
switch (size) {
case 'sm':
return 'h-6 w-6 border-2'
case 'md':
default:
return 'h-12 w-12 border-4'
}

View File

@@ -1,5 +1,5 @@
<template>
<div :class="cn('no-results-placeholder h-full p-8', className)">
<div class="no-results-placeholder h-full p-8" :class="props.class">
<Card>
<template #content>
<div class="flex flex-col items-center">
@@ -25,16 +25,8 @@
import Card from 'primevue/card'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
class: className,
icon,
title,
message,
textClass,
buttonLabel
} = defineProps<{
const props = defineProps<{
class?: string
icon?: string
title: string

View File

@@ -19,7 +19,7 @@ const i18n = createI18n({
}
})
describe((SearchBox as { __name?: string }).__name ?? 'SearchBox', () => {
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()

View File

@@ -18,7 +18,7 @@ export interface SearchFilter {
id: string | number
}
const { text, badge, badgeClass } = defineProps<Omit<SearchFilter, 'id'>>()
defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
</script>

View File

@@ -21,9 +21,9 @@
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.devices') }}
</h2>
<TabView v-if="stats.devices.length > 1">
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in stats.devices"
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
@@ -31,7 +31,7 @@
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="stats.devices[0]" />
<DeviceInfo v-else :device="props.stats.devices[0]" />
</div>
</template>
</div>
@@ -48,16 +48,16 @@ import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
const { stats } = defineProps<{
const props = defineProps<{
stats: SystemStats
}>()
const systemInfo = computed(() => ({
...stats.system,
argv: stats.system.argv.join(' ')
...props.stats.system,
argv: props.stats.system.argv.join(' ')
}))
const hasDevices = computed(() => stats.devices.length > 0)
const hasDevices = computed(() => props.stats.devices.length > 0)
type SystemInfoKey = keyof SystemStats['system']

View File

@@ -4,7 +4,7 @@
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
:class="className"
:class="props.class"
:value="renderedRoot.children"
selection-mode="single"
:pt="{
@@ -37,6 +37,10 @@
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
@@ -56,10 +60,6 @@ import type {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
defineOptions({
inheritAttrs: false
})
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
@@ -68,7 +68,7 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const { root, class: className } = defineProps<{
const props = defineProps<{
root: TreeExplorerNode
class?: string
}>()
@@ -90,7 +90,7 @@ const {
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(root)
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot

View File

@@ -19,7 +19,7 @@ const i18n = createI18n({
messages: {}
})
describe(TreeExplorerTreeNode.__name ?? 'TreeExplorerTreeNode', () => {
describe('TreeExplorerTreeNode', () => {
const mockNode = {
key: '1',
label: 'Test Node',

View File

@@ -5,21 +5,21 @@
'tree-node',
{
'can-drop': canDrop,
'tree-folder': !node.leaf,
'tree-leaf': node.leaf
'tree-folder': !props.node.leaf,
'tree-leaf': props.node.leaf
}
]"
:data-testid="`tree-node-${node.key}`"
>
<div class="node-content">
<span class="node-label">
<slot name="before-label" :node="node" />
<slot name="before-label" :node="props.node" />
<EditableText
:model-value="node.label"
:is-editing="isEditing"
@edit="handleRename"
/>
<slot name="after-label" :node="node" />
<slot name="after-label" :node="props.node" />
</span>
<Badge
v-if="showNodeBadgeText"
@@ -31,7 +31,7 @@
<div
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="node" />
<slot name="actions" :node="props.node" />
</div>
</div>
</template>
@@ -52,7 +52,7 @@ import type {
TreeExplorerDragAndDropData
} from '@/types/treeExplorerTypes'
const { node } = defineProps<{
const props = defineProps<{
node: RenderedTreeExplorerNode
}>()
@@ -67,20 +67,20 @@ const emit = defineEmits<{
}>()
const nodeBadgeText = computed<string>(() => {
if (node.leaf) {
if (props.node.leaf) {
return ''
}
if (node.badgeText !== undefined && node.badgeText !== null) {
return node.badgeText
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
return props.node.badgeText
}
return node.totalLeaves.toString()
return props.node.totalLeaves.toString()
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => node.isEditingLabel ?? false)
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel?.(node, newName)
handleEditLabel?.(props.node, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -89,21 +89,21 @@ const canDrop = ref(false)
const treeNodeElementGetter = () =>
container.value?.closest('.p-tree-node-content') as HTMLElement
if (node.draggable) {
if (props.node.draggable) {
usePragmaticDraggable(treeNodeElementGetter, {
getInitialData: () => {
return {
type: 'tree-explorer-node',
data: node
data: props.node
}
},
onDragStart: () => emit('dragStart', node),
onDrop: () => emit('dragEnd', node),
onGenerateDragPreview: node.renderDragPreview
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node),
onGenerateDragPreview: props.node.renderDragPreview
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return node.renderDragPreview?.(container)
return props.node.renderDragPreview?.(container)
},
nativeSetDragImage
})
@@ -112,14 +112,14 @@ if (node.draggable) {
})
}
if (node.droppable) {
if (props.node.droppable) {
usePragmaticDroppable(treeNodeElementGetter, {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await node.handleDrop?.(dndData)
await props.node.handleDrop?.(dndData)
canDrop.value = false
emit('itemDropped', node, dndData.data)
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {

View File

@@ -6,11 +6,10 @@ import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import UrlInput from './UrlInput.vue'
describe(UrlInput.__name ?? 'UrlInput', () => {
describe('UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -17,7 +17,7 @@
'pi pi-times cursor-pointer text-red-500':
validationState === ValidationState.INVALID
}"
@click="validateUrl(model)"
@click="validateUrl(props.modelValue)"
/>
</IconField>
</template>
@@ -32,34 +32,40 @@ import { isValidUrl } from '@/utils/formatUtil'
import { checkUrlReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const model = defineModel<string>({ required: true })
const { validateUrlFn } = defineProps<{
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'state-change': [state: ValidationState]
}>()
const validationState = ref<ValidationState>(ValidationState.IDLE)
const cleanInput = (value: string): string =>
value ? value.replaceAll(/\s+/g, '') : ''
value ? value.replace(/\s+/g, '') : ''
const internalValue = ref(cleanInput(model.value))
// Add internal value state
const internalValue = ref(cleanInput(props.modelValue))
watch(model, async (newValue: string) => {
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
})
// Watch for external modelValue changes
watch(
() => props.modelValue,
async (newValue: string) => {
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
}
)
watch(validationState, (newState) => {
emit('state-change', newState)
})
// Validate on mount
onMounted(async () => {
await validateUrl(model.value)
await validateUrl(props.modelValue)
})
const handleInput = (value: string | undefined) => {
@@ -81,7 +87,7 @@ const handleBlur = async () => {
}
// Emit the update only on blur
model.value = normalizedUrl
emit('update:modelValue', normalizedUrl)
}
// Default validation implementation
@@ -107,7 +113,7 @@ const validateUrl = async (value: string) => {
validationState.value = ValidationState.LOADING
try {
const isValid = await (validateUrlFn ?? defaultValidateUrl)(url)
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
validationState.value = isValid
? ValidationState.VALID
: ValidationState.INVALID

View File

@@ -23,7 +23,7 @@ const i18n = createI18n({
}
})
describe(UserAvatar.__name ?? 'UserAvatar', () => {
describe('UserAvatar', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -39,7 +39,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
describe(UserCredit.__name ?? 'UserCredit', () => {
describe('UserCredit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBalance.value = {

View File

@@ -444,6 +444,7 @@ const distributions = computed(() => {
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
@@ -594,10 +595,12 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
}
} else if (source === 'sort' && isPopularNav && !isPopularSort) {
} else if (source === 'sort') {
// When sort is changed away from 'Popular' while in the 'Popular' category,
// reset the category to 'All Templates' to avoid a confusing state.
selectedNavItem.value = 'all'
if (isPopularNav && !isPopularSort) {
selectedNavItem.value = 'all'
}
}
}
@@ -678,37 +681,37 @@ const runsOnOptions = computed(() =>
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
}
if (selectedModelObjects.value.length === 1) {
} else if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
}
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
}
if (selectedUseCaseObjects.value.length === 1) {
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
})
const runsOnFilterLabel = computed(() => {
if (selectedRunsOnObjects.value.length === 0) {
return t('templateWorkflows.runsOnFilter', 'Runs On')
}
if (selectedRunsOnObjects.value.length === 1) {
} else if (selectedRunsOnObjects.value.length === 1) {
return selectedRunsOnObjects.value[0].name
} else {
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
})
}
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
})
})
// Sort options

View File

@@ -26,7 +26,7 @@ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
return true
}
return
return undefined
}
onMounted(() => {

View File

@@ -6,7 +6,7 @@
</div>
</template>
<script setup lang="ts">
const { title } = defineProps<{
defineProps<{
title?: string
}>()
</script>

View File

@@ -120,13 +120,7 @@ import { useDialogService } from '@/services/dialogService'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const {
message,
type,
onConfirm: onConfirmProp,
itemList,
hint
} = defineProps<{
const props = defineProps<{
message: string
type: ConfirmationDialogType
onConfirm: (value?: boolean) => void
@@ -149,14 +143,14 @@ function openBlueprintOverwriteSetting() {
const doNotAskAgain = ref(false)
const onDeny = () => {
onConfirmProp(false)
props.onConfirm(false)
useDialogStore().closeDialog()
}
const onConfirm = () => {
if (type === 'overwriteBlueprint' && doNotAskAgain.value)
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
onConfirmProp(true)
props.onConfirm(true)
useDialogStore().closeDialog()
}
</script>

View File

@@ -30,7 +30,7 @@ const createMockNode = (type: string, version?: string): LGraphNode =>
outputs: []
})
describe(MissingCoreNodesMessage.__name ?? 'MissingCoreNodesMessage', () => {
describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
refetchSystemStats: vi.fn()

View File

@@ -94,7 +94,7 @@ interface ModelInfo {
folder_path?: string
}
const { missingModels: missingModelsProp, paths } = defineProps<{
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
@@ -113,9 +113,9 @@ function openShowMissingModelsSetting() {
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return missingModelsProp.map((model) => {
const modelPaths = paths[model.directory]
if (model.directory_invalid || !modelPaths) {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (model.directory_invalid || !paths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
@@ -130,7 +130,7 @@ const missingModels = computed(() => {
name: model.name,
directory: model.directory,
url: model.url,
folder_path: modelPaths[0]
folder_path: paths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!whiteListedUrls.has(model.url)) {
@@ -157,7 +157,7 @@ const missingModels = computed(() => {
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: modelPaths,
paths: paths,
folderPath: downloadInfo.folder_path
}
})

View File

@@ -1,12 +1,12 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
@@ -14,32 +14,210 @@
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- Missing Nodes List Wrapper -->
<div
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<!-- QUICK FIX AVAILABLE Section -->
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<Button
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
variant="primary"
size="md"
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
})
}}
</Button>
</div>
<!-- Replaceable nodes list -->
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<span class="text-xs">
{{ node.label }}
</span>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
<!-- Select All row (sticky header) -->
<div
:class="
cn(
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
"
@click="toggleSelectAll"
@keydown.enter.prevent="toggleSelectAll"
@keydown.space.prevent="toggleSelectAll"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
isAllSelected || isSomeSelected
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isAllSelected"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
<!-- Replaceable node items -->
<div
v-for="node in replaceableNodes"
:key="node.label"
:class="
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'true'
: 'false'
"
@click="toggleNode(node.label)"
@keydown.enter.prevent="toggleNode(node.label)"
@keydown.space.prevent="toggleNode(node.label)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
</span>
</div>
</div>
</div>
</div>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
<!-- MANUAL INSTALLATION REQUIRED Section -->
<div
v-if="nonReplaceableNodes.length > 0"
class="flex max-h-[200px] flex-col gap-2"
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
</div>
<!-- Non-replaceable nodes list -->
<div
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
:key="node.label"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
</div>
<Button
v-if="node.action"
variant="destructive-textonly"
size="sm"
@click="node.action.callback"
>
{{ node.action.text }}
</Button>
</div>
</div>
</div>
<!-- Bottom instruction box -->
<div
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<p class="m-0 text-xs leading-5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{
$t('nodeReplacement.redHighlight')
}}</span>
</template>
</i18n-t>
</p>
</div>
</div>
@@ -47,22 +225,38 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
interface ProcessedNode {
label: string
hint?: string
action?: { text: string; callback: () => void }
isReplaceable: boolean
replacement?: NodeReplacement
}
const replacedTypes = ref<Set<string>>(new Set())
const uniqueNodes = computed<ProcessedNode[]>(() => {
const seenTypes = new Set<string>()
return missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
@@ -75,10 +269,81 @@ const uniqueNodes = computed(() => {
return {
label: node.type,
hint: node.hint,
action: node.action
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
}
}
return { label: node }
return { label: node, isReplaceable: false }
})
})
const replaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => n.isReplaceable)
)
const pendingNodes = computed(() =>
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
)
const nonReplaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => !n.isReplaceable)
)
// Selection state - all pending nodes selected by default
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
const isAllSelected = computed(
() =>
pendingNodes.value.length > 0 &&
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
)
const isSomeSelected = computed(
() => selectedTypes.value.size > 0 && !isAllSelected.value
)
function toggleNode(label: string) {
if (replacedTypes.value.has(label)) return
const next = new Set(selectedTypes.value)
if (next.has(label)) {
next.delete(label)
} else {
next.add(label)
}
selectedTypes.value = next
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedTypes.value = new Set()
} else {
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
}
}
function handleReplaceSelected() {
const selected = missingNodeTypes.filter((node) => {
const type = typeof node === 'object' ? node.type : node
return selectedTypes.value.has(type)
})
const result = replaceNodesInPlace(selected)
const nextReplaced = new Set(replacedTypes.value)
const nextSelected = new Set(selectedTypes.value)
for (const type of result) {
nextReplaced.add(type)
nextSelected.delete(type)
}
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)
)
if (allReplaced && nonReplaceableNodes.value.length === 0) {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
}
</script>

View File

@@ -30,8 +30,18 @@
</i18n-t>
</div>
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleGotItClick">
{{ $t('nodeReplacement.skipForNow') }}
</Button>
</div>
<!-- Cloud mode: Learn More + Got It buttons -->
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<div
v-else-if="isCloud"
class="flex w-full items-center justify-between gap-2"
>
<Button
variant="textonly"
size="sm"
@@ -48,9 +58,9 @@
}}</Button>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<!-- OSS mode: Manager buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="openManager">{{
<Button variant="textonly" @click="handleOpenManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
@@ -82,12 +92,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -112,6 +127,12 @@ function openShowMissingNodesSetting() {
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
function handleOpenManager() {
managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -131,15 +152,29 @@ const showInstallAllButton = computed(() => {
return managerState.shouldShowInstallButton.value
})
const openManager = async () => {
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
const hasNonReplaceableNodes = computed(
() =>
missingNodeTypes?.some(
(n) =>
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
) ?? false
)
// Computed to check if all missing nodes have been installed
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
const hadMissingPacks = ref(false)
watch(
missingNodePacks,
(packs) => {
if (packs && packs.length > 0) hadMissingPacks.value = true
},
{ immediate: true }
)
// Only consider "all installed" when packs transitioned from non-empty to empty
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
const allMissingNodesInstalled = computed(() => {
if (!hadMissingPacks.value) return false
return (
!isLoading.value &&
!isInstalling.value &&

View File

@@ -25,22 +25,17 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const {
message,
defaultValue,
onConfirm: onConfirmProp,
placeholder
} = defineProps<{
const props = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(defaultValue)
const inputValue = ref<string>(props.defaultValue)
const onConfirm = () => {
onConfirmProp(inputValue.value)
props.onConfirm(inputValue.value)
useDialogStore().closeDialog()
}

View File

@@ -25,7 +25,7 @@ const mountOption = (
}
})
describe(CreditTopUpOption.__name ?? 'CreditTopUpOption', () => {
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')

View File

@@ -11,20 +11,23 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useTelemetry } from '@/platform/telemetry'
const { errorMessage, repoOwner, repoName } = defineProps<{
const props = defineProps<{
errorMessage: string
repoOwner: string
repoName: string
}>()
const queryString = computed(() => `${errorMessage} is:issue`)
const queryString = computed(() => props.errorMessage + ' is:issue')
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${repoOwner}/${repoName}/issues?q=${query}`
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
window.open(url, '_blank')
}
</script>

View File

@@ -268,7 +268,7 @@ async function saveKeybinding() {
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == null) return
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel :value class="h-full w-full" :class="panelClass">
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<div class="flex h-full w-full flex-col gap-2">
<slot name="header" />
<ScrollPanel class="h-0 grow pr-2">
@@ -14,7 +14,7 @@
import ScrollPanel from 'primevue/scrollpanel'
import TabPanel from 'primevue/tabpanel'
const { value, class: panelClass } = defineProps<{
const props = defineProps<{
value: string
class?: string
}>()

View File

@@ -17,7 +17,7 @@ vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: vi.fn()
}))
describe(SettingItem.__name ?? 'SettingItem', () => {
describe('SettingItem', () => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
return mount(SettingItem, {
global: {

View File

@@ -76,7 +76,7 @@ const i18n = createI18n({
}
})
describe(UsageLogsTable.__name ?? 'UsageLogsTable', () => {
describe('UsageLogsTable', () => {
const mockEventsResponse = {
events: [
{

View File

@@ -169,10 +169,9 @@ const loadEvents = async () => {
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}
} catch (errorCaught) {
error.value =
errorCaught instanceof Error ? errorCaught.message : 'Unknown error'
console.error('Error loading events:', errorCaught)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
console.error('Error loading events:', err)
} finally {
loading.value = false
}

View File

@@ -56,7 +56,7 @@ const i18n = createI18n({
}
})
describe(ApiKeyForm.__name ?? 'ApiKeyForm', () => {
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -58,7 +58,7 @@ vi.mock('primevue/usetoast', () => ({
}))
}))
describe(SignInForm.__name ?? 'SignInForm', () => {
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
@@ -110,17 +110,12 @@ describe(SignInForm.__name ?? 'SignInForm', () => {
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock querySelector to track focus on the email input
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
const originalQuerySelector = document.querySelector.bind(document)
const spy = vi
.spyOn(document, 'querySelector')
.mockImplementation((selector: string) => {
if (selector === '#comfy-org-sign-in-email')
return mockElement as HTMLElement
return originalQuerySelector(selector)
})
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
@@ -134,9 +129,10 @@ describe(SignInForm.__name ?? 'SignInForm', () => {
})
// Should focus email input
expect(spy).toHaveBeenCalledWith('#comfy-org-sign-in-email')
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
spy.mockRestore()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
@@ -216,7 +212,7 @@ describe(SignInForm.__name ?? 'SignInForm', () => {
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch {
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
@@ -274,25 +270,21 @@ describe(SignInForm.__name ?? 'SignInForm', () => {
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock querySelector to track focus on the email input
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
const originalQuerySelector = document.querySelector.bind(document)
const spy = vi
.spyOn(document, 'querySelector')
.mockImplementation((selector: string) => {
if (selector === '#comfy-org-sign-in-email')
return mockElement as HTMLElement
return originalQuerySelector(selector)
})
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(spy).toHaveBeenCalledWith('#comfy-org-sign-in-email')
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
spy.mockRestore()
})
it('does not focus email input when valid email is provided', async () => {

View File

@@ -120,7 +120,7 @@ const handleForgotPassword = async (
life: 5_000
})
// Focus the email input
document.querySelector<HTMLElement>(`#${emailInputId}`)?.focus?.()
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)

View File

@@ -52,7 +52,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const { cancelAt } = defineProps<{
const props = defineProps<{
cancelAt?: string
}>()
@@ -64,7 +64,7 @@ const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const formattedEndDate = computed(() => {
const dateStr = cancelAt ?? subscription.value?.endDate
const dateStr = props.cancelAt ?? subscription.value?.endDate
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {

View File

@@ -66,7 +66,7 @@ interface Props {
buttonStyles?: Record<string, string>
}
const { buttonStyles } = defineProps<Props>()
defineProps<Props>()
const buttonRef = ref<ComponentPublicInstance | null>(null)
const popover = ref<InstanceType<typeof Popover>>()
const commandStore = useCommandStore()

View File

@@ -65,12 +65,11 @@ const updateWidgets = () => {
const canvasStore = useCanvasStore()
whenever(
() => canvasStore.canvas,
(canvas) => {
canvas.onDrawForeground = useChainCallback(
(canvas) =>
(canvas.onDrawForeground = useChainCallback(
canvas.onDrawForeground,
updateWidgets
)
},
)),
{ immediate: true }
)
</script>

View File

@@ -162,8 +162,6 @@ import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { storeToRefs } from 'pinia'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -173,9 +171,11 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
const emit = defineEmits<{
@@ -248,9 +248,9 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] => [
...(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
])
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return

View File

@@ -114,8 +114,8 @@ const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
useZoomControls()
const stringifiedMinimapStyles = computed(() => {
const buttonGroupKeys = new Set(['borderRadius'])
const buttonKeys = new Set(['borderRadius'])
const buttonGroupKeys = ['borderRadius']
const buttonKeys = ['borderRadius']
const additionalButtonStyles = {
border: 'none'
}
@@ -124,12 +124,14 @@ const stringifiedMinimapStyles = computed(() => {
const buttonStyles = {
...Object.fromEntries(
Object.entries(containerStyles).filter(([key]) => buttonKeys.has(key))
Object.entries(containerStyles).filter(([key]) =>
buttonKeys.includes(key)
)
),
...additionalButtonStyles
}
const buttonGroupStyles = Object.entries(containerStyles)
.filter(([key]) => buttonGroupKeys.has(key))
.filter(([key]) => buttonGroupKeys.includes(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
return { buttonStyles, buttonGroupStyles }

View File

@@ -248,8 +248,8 @@ defineExpose({ toggle, hide, isOpen, show })
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = [...(event.currentTarget as HTMLElement).children].find((el) =>
el.classList.contains('icon-[lucide--chevron-right]')
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(event, target)
}

View File

@@ -34,14 +34,14 @@ const left = ref<string>()
const top = ref<string>()
function hideTooltip() {
tooltipText.value = ''
return (tooltipText.value = '')
}
async function showTooltip(tooltip: string | null | undefined) {
if (!tooltip) return
left.value = `${comfyApp.canvas.mouse[0]}px`
top.value = `${comfyApp.canvas.mouse[1]}px`
left.value = comfyApp.canvas.mouse[0] + 'px'
top.value = comfyApp.canvas.mouse[1] + 'px'
tooltipText.value = tooltip
await nextTick()
@@ -50,11 +50,11 @@ async function showTooltip(tooltip: string | null | undefined) {
if (!rect) return
if (rect.right > window.innerWidth) {
left.value = `${comfyApp.canvas.mouse[0] - rect.width}px`
left.value = comfyApp.canvas.mouse[0] - rect.width + 'px'
}
if (rect.top < 0) {
top.value = `${comfyApp.canvas.mouse[1] + rect.height}px`
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
}
}

View File

@@ -102,7 +102,7 @@ vi.mock('@/stores/nodeDefStore', () => ({
})
}))
describe(SelectionToolbox.__name ?? 'SelectionToolbox', () => {
describe('SelectionToolbox', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
const i18n = createI18n({

View File

@@ -82,14 +82,16 @@ const { visible } = useSelectionToolboxPosition(toolboxRef)
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems.flatMap(
(item) =>
extensionService
.invokeExtensions('getSelectionToolboxCommands', item)
.flat() as string[]
)
canvasStore.selectedItems
.map(
(item) =>
extensionService
.invokeExtensions('getSelectionToolboxCommands', item)
.flat() as string[]
)
.flat()
)
return [...commandIds]
return Array.from(commandIds)
.map((commandId) => commandStore.getCommand(commandId))
.filter((command): command is ComfyCommandImpl => command !== undefined)
})

View File

@@ -68,7 +68,7 @@ const createWrapper = (props = {}) => {
})
}
describe(ZoomControlsModal.__name ?? 'ZoomControlsModal', () => {
describe('ZoomControlsModal', () => {
beforeEach(() => {
vi.resetAllMocks()
})

View File

@@ -84,7 +84,7 @@ interface Props {
visible: boolean
}
const { visible } = defineProps<Props>()
const props = defineProps<Props>()
const interval = ref<number | null>(null)
@@ -132,7 +132,7 @@ const zoomToFitCommandText = computed(() =>
const zoomInputContainer = ref<HTMLDivElement | null>(null)
watch(
() => visible,
() => props.visible,
async (newVal) => {
if (newVal) {
await nextTick()

View File

@@ -20,7 +20,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
describe(BypassButton.__name ?? 'BypassButton', () => {
describe('BypassButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let commandStore: ReturnType<typeof useCommandStore>

View File

@@ -45,7 +45,7 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => `${color}_light`)
adjustColor: vi.fn((color: string) => color + '_light')
}))
// Mock the litegraphUtil module
@@ -56,7 +56,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
isReroute: vi.fn(() => false)
}))
describe(ColorPickerButton.__name ?? 'ColorPickerButton', () => {
describe('ColorPickerButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let workflowStore: ReturnType<typeof useWorkflowStore>

View File

@@ -80,7 +80,7 @@ interface Emits {
(e: 'submenu-click', subOption: SubMenuOption): void
}
const { option } = defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { getCurrentShape } = useNodeCustomization()
@@ -113,9 +113,9 @@ const isShapeSelected = (subOption: SubMenuOption): boolean => {
const isColorSubmenu = computed(() => {
return (
option.submenu &&
option.submenu.length > 0 &&
option.submenu.every((item) => item.color && !item.icon)
props.option.submenu &&
props.option.submenu.length > 0 &&
props.option.submenu.every((item) => item.color && !item.icon)
)
})
</script>

View File

@@ -30,7 +30,7 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
}))
}))
describe(ExecuteButton.__name ?? 'ExecuteButton', () => {
describe('ExecuteButton', () => {
let mockCanvas: LGraphCanvas
let mockSelectedNodes: LGraphNode[]

View File

@@ -24,7 +24,7 @@ import type { ComfyCommand } from '@/stores/commandStore'
import { useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { command } = defineProps<{
defineProps<{
command: ComfyCommand
}>()

View File

@@ -18,7 +18,7 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
})
}))
describe(InfoButton.__name ?? 'InfoButton', () => {
describe('InfoButton', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -20,7 +20,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
const { nodeId } = defineProps<{
const props = defineProps<{
nodeId: NodeId
}>()
@@ -30,7 +30,7 @@ const formattedText = computed(() => {
const src = modelValue.value
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
const tokens: { label: string; url: string }[] = []
const holed = src.replaceAll(
const holed = src.replace(
/\[\[([^|\]]+)\|([^\]]+)\]\]/g,
(_m, label, url) => {
tokens.push({ label: String(label), url: String(url) })
@@ -42,10 +42,10 @@ const formattedText = computed(() => {
let html = nl2br(linkifyHtml(holed))
// Restore placeholders as <a>...</a> (minimal escaping + http default)
html = html.replaceAll(/__LNK(\d+)__/g, (_m, i) => {
html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
const { label, url } = tokens[+i]
const safeHref = url.replaceAll('"', '&quot;')
const safeLabel = label.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
const safeHref = url.replace(/"/g, '&quot;')
const safeLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;')
return /^https?:\/\//i.test(url)
? `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`
: safeLabel
@@ -58,7 +58,7 @@ let parentNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = nodeId
parentNodeId = props.nodeId
})
// Watch for either a new node has starting execution or overall execution ending

View File

@@ -593,11 +593,11 @@ const onUpdateComfyUI = async (): Promise<void> => {
})
await rebootComfyUI()
} catch (error) {
} catch (err) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
})
}

View File

@@ -5,7 +5,7 @@ import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe(HoneyToast.__name ?? 'HoneyToast', () => {
describe('HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''

View File

@@ -110,7 +110,7 @@ import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
const { nodeId } = defineProps<{
const props = defineProps<{
nodeId: NodeId
}>()
@@ -142,5 +142,5 @@ const {
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue })
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -170,7 +170,7 @@ const getLabel = (val: string | null | undefined) => {
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)

View File

@@ -84,24 +84,24 @@ import { app } from '@/scripts/app'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const { widget, nodeId } = defineProps<{
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
function isComponentWidget(
w: ComponentWidget<string[]> | SimplifiedWidget
): w is ComponentWidget<string[]> {
return 'node' in w && w.node !== undefined
widget: ComponentWidget<string[]> | SimplifiedWidget
): widget is ComponentWidget<string[]> {
return 'node' in widget && widget.node !== undefined
}
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(nodeId!) || null
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
})
}

View File

@@ -35,14 +35,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
const {
initializeLoad3d,
cleanup,
loading,
loadingMessage,
onModelDrop,
isPreview
} = defineProps<{
const props = defineProps<{
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
cleanup: () => void
loading: boolean
@@ -60,20 +53,20 @@ function focusContainer() {
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
if (onModelDrop) {
await onModelDrop(file)
if (props.onModelDrop) {
await props.onModelDrop(file)
}
},
disabled: computed(() => isPreview)
disabled: computed(() => props.isPreview)
})
onMounted(() => {
if (container.value) {
void initializeLoad3d(container.value)
void props.initializeLoad3d(container.value)
}
})
onUnmounted(() => {
cleanup()
props.cleanup()
})
</script>

View File

@@ -110,7 +110,7 @@ import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const { node, modelUrl } = defineProps<{
const props = defineProps<{
node?: LGraphNode
modelUrl?: string
}>()
@@ -120,10 +120,11 @@ const containerRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)
const isStandaloneMode = !node && modelUrl
const isStandaloneMode = !props.node && props.modelUrl
const viewer = node
? useLoad3dService().getOrCreateViewerSync(toRaw(node), useLoad3dViewer)
// Use sync version since useLoad3dViewer is already imported (module is loaded)
const viewer = props.node
? useLoad3dService().getOrCreateViewerSync(toRaw(props.node), useLoad3dViewer)
: useLoad3dViewer()
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
@@ -137,10 +138,10 @@ const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
onMounted(async () => {
if (!containerRef.value) return
if (isStandaloneMode && modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, modelUrl)
} else if (node) {
const source = useLoad3dService().getLoad3d(node)
if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}

View File

@@ -69,7 +69,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
'backgroundRenderMode'
)
const { hasBackgroundImage, disableBackgroundUpload } = defineProps<{
defineProps<{
hasBackgroundImage?: boolean
disableBackgroundUpload?: boolean
}>()

View File

@@ -22,10 +22,10 @@ const currentPanelComponent = computed<Component>(() => {
if (tool === Tools.MaskBucket) {
return PaintBucketSettingsPanel
}
if (tool === Tools.MaskColorFill) {
} else if (tool === Tools.MaskColorFill) {
return ColorSelectSettingsPanel
} else {
return BrushSettingsPanel
}
return BrushSettingsPanel
})
</script>

Some files were not shown because too many files have changed in this diff Show More