Merge branch 'main' into feat/survey-integration
@@ -5,3 +5,10 @@ reviews:
|
||||
high_level_summary: false
|
||||
auto_review:
|
||||
drafts: true
|
||||
ignore_title_keywords:
|
||||
- '[release]'
|
||||
- '[backport'
|
||||
ignore_usernames:
|
||||
- comfy-pr-bot
|
||||
- github-actions
|
||||
- github-actions[bot]
|
||||
|
||||
45
.github/workflows/cloud-dispatch-build.yaml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# Dispatches a frontend-asset-build event to the cloud repo on push to
|
||||
# cloud/* branches and main. The cloud repo handles the actual build,
|
||||
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
|
||||
#
|
||||
# This is fire-and-forget — it does NOT wait for the cloud workflow to
|
||||
# complete. Status is visible in the cloud repo's Actions tab.
|
||||
|
||||
name: Cloud Frontend Build Dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'cloud/*'
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: cloud-dispatch-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Fork guard: prevent forks from dispatching to the cloud repo
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
id: payload
|
||||
run: |
|
||||
payload="$(jq -nc \
|
||||
--arg ref "${GITHUB_SHA}" \
|
||||
--arg branch "${GITHUB_REF_NAME}" \
|
||||
'{ref: $ref, branch: $branch}')"
|
||||
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch to cloud repo
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
|
||||
repository: Comfy-Org/cloud
|
||||
event-type: frontend-asset-build
|
||||
client-payload: ${{ steps.payload.outputs.json }}
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"no-eval": "off",
|
||||
"no-eval": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
||||
760
browser_tests/assets/subgraphs/subgraph-nested-promotion.json
Normal file
@@ -0,0 +1,760 @@
|
||||
{
|
||||
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 18,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1031, 434],
|
||||
"size": [250, 178],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
},
|
||||
"widgets_values": [null, null, null]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"pos": [788, 433.5],
|
||||
"size": [225, 380],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["3", "string_a"],
|
||||
["4", "value"],
|
||||
["6", "value"],
|
||||
["6", "value_1"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [548, 451],
|
||||
"size": [225, 142],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"title": "Outer",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Outer\n"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[4, 1, 0, 5, 0, "STRING"],
|
||||
[5, 5, 0, 2, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 0",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [351, 432.5, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1352, 294.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"localized_name": "string_a",
|
||||
"pos": [451, 452.5]
|
||||
},
|
||||
{
|
||||
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [13],
|
||||
"pos": [451, 472.5]
|
||||
},
|
||||
{
|
||||
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [16],
|
||||
"pos": [451, 492.5]
|
||||
},
|
||||
{
|
||||
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
|
||||
"name": "value_1_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [18],
|
||||
"pos": [451, 512.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1372, 314.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [504, 437],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"title": "Inner 1",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 1\n"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [743, 325],
|
||||
"size": [347, 231],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"pos": [1115, 301],
|
||||
"size": [210, 196],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value_1"
|
||||
},
|
||||
"link": 18
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["5", "string_a"],
|
||||
["11", "value"],
|
||||
["9", "value"],
|
||||
["10", "string_a"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 4,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 6,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 6,
|
||||
"target_slot": 2,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 1",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [180, 739, 120, 100]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1246, 612, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "string_a",
|
||||
"pos": [280, 759]
|
||||
},
|
||||
{
|
||||
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [14],
|
||||
"pos": [280, 779]
|
||||
},
|
||||
{
|
||||
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [17],
|
||||
"pos": [280, 799]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [12],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1266, 632]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [334, 742],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 14
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"title": "Inner 2",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 2\n"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [581, 637],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"pos": [1004, 613],
|
||||
"size": [210, 142],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 17
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "string_a"],
|
||||
["8", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 9,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 9,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 2",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [262, 1222, 120, 80]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "string_a",
|
||||
"pos": [362, 1242]
|
||||
},
|
||||
{
|
||||
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [15],
|
||||
"pos": [362, 1262]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1143.089999999999, 1145.1999999999998]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [412.96000000000004, 1228.2399999999996],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"title": "Inner 3",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 3\n"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [686.08, 1132.38],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 8,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-412, 11]
|
||||
},
|
||||
"frontendVersion": "1.41.7"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@@ -67,4 +67,66 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
recordMeasurement(m)
|
||||
console.log(`Clipping: ${m.layouts} forced layouts`)
|
||||
})
|
||||
|
||||
test('subgraph idle style recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-idle')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
|
||||
)
|
||||
})
|
||||
|
||||
test('subgraph mouse interaction style recalculations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + (box.width * i) / 100,
|
||||
box.y + (box.height * (i % 3)) / 3
|
||||
)
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-mouse-sweep')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
|
||||
)
|
||||
})
|
||||
|
||||
test('subgraph DOM widget clipping during node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width / 3 + (i % 5) * 30,
|
||||
box.y + box.height / 3 + (i % 4) * 30
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-dom-widget-clipping')
|
||||
recordMeasurement(m)
|
||||
console.log(`Subgraph clipping: ${m.layouts} forced layouts`)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -555,6 +555,74 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
|
||||
// slot connected externally from the Outer node, so it should be
|
||||
// disabled. The remaining promoted textarea widgets (value, value_1)
|
||||
// are unlinked and should be enabled.
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames).toContain('string_a')
|
||||
expect(promotedNames).toContain('value')
|
||||
|
||||
const disabledState = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||
return (node?.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
disabled: !!w.computedDisabled
|
||||
}))
|
||||
})
|
||||
|
||||
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
|
||||
expect(linkedWidget?.disabled).toBe(true)
|
||||
|
||||
const unlinkedWidgets = disabledState.filter(
|
||||
(w) => w.name !== 'string_a'
|
||||
)
|
||||
for (const w of unlinkedWidgets) {
|
||||
expect(w.disabled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The promoted textareas that are NOT externally linked should be
|
||||
// fully opaque and interactive.
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
const wrapper = textarea.locator('..')
|
||||
const opacity = await wrapper.evaluate(
|
||||
(el) => getComputedStyle(el).opacity
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
comfyPage
|
||||
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
@@ -1,6 +1,7 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import pluginJs from '@eslint/js'
|
||||
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
|
||||
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
@@ -111,6 +112,28 @@ export default defineConfig([
|
||||
tseslintConfigs.recommended,
|
||||
// Difference in typecheck on CI vs Local
|
||||
pluginVue.configs['flat/recommended'],
|
||||
// Tailwind CSS v4 linting (class ordering, duplicates, conflicts, etc.)
|
||||
betterTailwindcss.configs.recommended,
|
||||
{
|
||||
settings: {
|
||||
'better-tailwindcss': {
|
||||
entryPoint: 'packages/design-system/src/css/style.css'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Off: requires whitelisting non-Tailwind classes (PrimeIcons, custom CSS)
|
||||
'better-tailwindcss/no-unknown-classes': 'off',
|
||||
// Off: may conflict with oxfmt formatting
|
||||
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
|
||||
// Off: large batch change, enable and apply with `eslint --fix`
|
||||
'better-tailwindcss/enforce-consistent-class-order': 'off',
|
||||
// Off: large batch change (v3→v4 renames like rounded→rounded-sm),
|
||||
// enable and apply with `eslint --fix` in a follow-up PR
|
||||
'better-tailwindcss/enforce-canonical-classes': 'off',
|
||||
// Off: large batch change, enable and apply with `eslint --fix`
|
||||
'better-tailwindcss/no-deprecated-classes': 'off'
|
||||
}
|
||||
},
|
||||
// Disables ESLint rules that conflict with formatters
|
||||
eslintConfigPrettier,
|
||||
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
||||
|
||||
@@ -41,7 +41,9 @@ const config: KnipConfig = {
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml'
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.8",
|
||||
"version": "1.41.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -146,6 +146,7 @@
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-import-resolver-typescript": "catalog:",
|
||||
"eslint-plugin-better-tailwindcss": "catalog:",
|
||||
"eslint-plugin-import-x": "catalog:",
|
||||
"eslint-plugin-oxlint": "catalog:",
|
||||
"eslint-plugin-storybook": "catalog:",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
|
||||
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
#3e1ffc 65.17%,
|
||||
#009dff 103.86%
|
||||
),
|
||||
var(--color-button-surface, #2d2e32);
|
||||
linear-gradient(var(--color-button-surface, #2d2e32));
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
--code-text-color: rgb(0 122 255 / 1);
|
||||
@@ -358,26 +358,6 @@
|
||||
--button-active-surface: var(--color-charcoal-600);
|
||||
--button-icon: var(--color-smoke-800);
|
||||
|
||||
--subscription-button-gradient:
|
||||
linear-gradient(
|
||||
315deg,
|
||||
rgb(105 230 255 / 0.15) 0%,
|
||||
rgb(99 73 233 / 0.5) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
70.71% 70.71% at 50% 50%,
|
||||
rgb(62 99 222 / 0.15) 0.01%,
|
||||
rgb(66 0 123 / 0.5) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
92deg,
|
||||
#d000ff 0.38%,
|
||||
#b009fe 37.07%,
|
||||
#3e1ffc 65.17%,
|
||||
#009dff 103.86%
|
||||
),
|
||||
var(--color-button-surface, #2d2e32);
|
||||
|
||||
--dialog-surface: var(--color-neutral-700);
|
||||
|
||||
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
||||
@@ -490,7 +470,6 @@
|
||||
--color-button-icon: var(--button-icon);
|
||||
--color-button-surface: var(--button-surface);
|
||||
--color-button-surface-contrast: var(--button-surface-contrast);
|
||||
--color-subscription-button-gradient: var(--subscription-button-gradient);
|
||||
|
||||
--color-modal-card-background: var(--modal-card-background);
|
||||
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
|
||||
@@ -634,6 +613,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility highlight {
|
||||
background-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
3
packages/design-system/src/icons/batch-images-node.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
|
||||
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
|
||||
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 652 B After Width: | Height: | Size: 652 B |
3
packages/design-system/src/icons/send.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5693 3L9.67677 14.099C9.59169 14.292 9.33927 14.3393 9.19012 14.1901L5.5 10.5M14.5693 3L1.65457 8.23468C1.40927 8.33411 1.40355 8.67936 1.64543 8.78686L5.5 10.5M14.5693 3L5.5 10.5M5.5 10.5C5.66712 10.5669 5.37259 10.3728 5.5 10.5ZM5.5 10.5C5.62741 10.6272 5.43279 10.333 5.5 10.5ZM5.5 10.5V13.5L7 12" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
3
packages/design-system/src/icons/video-slice.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
@@ -198,6 +202,147 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilenameDetails', () => {
|
||||
it('splits simple filenames into name and suffix', () => {
|
||||
expect(getFilenameDetails('file.txt')).toEqual({
|
||||
filename: 'file',
|
||||
suffix: 'txt'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles filenames with multiple dots', () => {
|
||||
expect(getFilenameDetails('my.file.name.png')).toEqual({
|
||||
filename: 'my.file.name',
|
||||
suffix: 'png'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles filenames without extension', () => {
|
||||
expect(getFilenameDetails('README')).toEqual({
|
||||
filename: 'README',
|
||||
suffix: null
|
||||
})
|
||||
})
|
||||
|
||||
it('recognises .app.json as a compound extension', () => {
|
||||
expect(getFilenameDetails('workflow.app.json')).toEqual({
|
||||
filename: 'workflow',
|
||||
suffix: 'app.json'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognises .app.json case-insensitively', () => {
|
||||
expect(getFilenameDetails('Workflow.APP.JSON')).toEqual({
|
||||
filename: 'Workflow',
|
||||
suffix: 'app.json'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles regular .json files normally', () => {
|
||||
expect(getFilenameDetails('workflow.json')).toEqual({
|
||||
filename: 'workflow',
|
||||
suffix: 'json'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats bare .app.json as a dotfile without basename', () => {
|
||||
expect(getFilenameDetails('.app.json')).toEqual({
|
||||
filename: '.app',
|
||||
suffix: 'json'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPathDetails', () => {
|
||||
it('splits a path with .app.json extension', () => {
|
||||
const result = getPathDetails('workflows/test.app.json')
|
||||
expect(result).toEqual({
|
||||
directory: 'workflows',
|
||||
fullFilename: 'test.app.json',
|
||||
filename: 'test',
|
||||
suffix: 'app.json'
|
||||
})
|
||||
})
|
||||
|
||||
it('splits a path with .json extension', () => {
|
||||
const result = getPathDetails('workflows/test.json')
|
||||
expect(result).toEqual({
|
||||
directory: 'workflows',
|
||||
fullFilename: 'test.json',
|
||||
filename: 'test',
|
||||
suffix: 'json'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
})
|
||||
|
||||
it('appends .json when isApp is false', () => {
|
||||
expect(appendWorkflowJsonExt('test', false)).toBe('test.json')
|
||||
})
|
||||
|
||||
it('replaces .json with .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test.json', true)).toBe('test.app.json')
|
||||
})
|
||||
|
||||
it('replaces .app.json with .json when isApp is false', () => {
|
||||
expect(appendWorkflowJsonExt('test.app.json', false)).toBe('test.json')
|
||||
})
|
||||
|
||||
it('leaves .app.json unchanged when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test.app.json', true)).toBe('test.app.json')
|
||||
})
|
||||
|
||||
it('leaves .json unchanged when isApp is false', () => {
|
||||
expect(appendWorkflowJsonExt('test.json', false)).toBe('test.json')
|
||||
})
|
||||
|
||||
it('handles case-insensitive extensions', () => {
|
||||
expect(appendWorkflowJsonExt('test.JSON', true)).toBe('test.app.json')
|
||||
expect(appendWorkflowJsonExt('test.APP.JSON', false)).toBe('test.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureWorkflowSuffix', () => {
|
||||
it('appends suffix when missing', () => {
|
||||
expect(ensureWorkflowSuffix('file', 'json')).toBe('file.json')
|
||||
})
|
||||
|
||||
it('does not double-append when suffix already present', () => {
|
||||
expect(ensureWorkflowSuffix('file.json', 'json')).toBe('file.json')
|
||||
})
|
||||
|
||||
it('appends compound suffix when missing', () => {
|
||||
expect(ensureWorkflowSuffix('file', 'app.json')).toBe('file.app.json')
|
||||
})
|
||||
|
||||
it('does not double-append compound suffix', () => {
|
||||
expect(ensureWorkflowSuffix('file.app.json', 'app.json')).toBe(
|
||||
'file.app.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces .json with .app.json when suffix is app.json', () => {
|
||||
expect(ensureWorkflowSuffix('file.json', 'app.json')).toBe(
|
||||
'file.app.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces .app.json with .json when suffix is json', () => {
|
||||
expect(ensureWorkflowSuffix('file.app.json', 'json')).toBe('file.json')
|
||||
})
|
||||
|
||||
it('handles case-insensitive extension detection', () => {
|
||||
expect(ensureWorkflowSuffix('file.JSON', 'json')).toBe('file.json')
|
||||
expect(ensureWorkflowSuffix('file.APP.JSON', 'app.json')).toBe(
|
||||
'file.app.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
|
||||
@@ -26,13 +26,44 @@ export function formatCamelCase(str: string): string {
|
||||
return processedWords.join(' ')
|
||||
}
|
||||
|
||||
// Metadata cannot be associated with workflows, so extension encodes the mode.
|
||||
const JSON_SUFFIX = 'json'
|
||||
const APP_JSON_SUFFIX = `app.${JSON_SUFFIX}`
|
||||
const JSON_EXT = `.${JSON_SUFFIX}`
|
||||
const APP_JSON_EXT = `.${APP_JSON_SUFFIX}`
|
||||
|
||||
export function appendJsonExt(path: string) {
|
||||
if (!path.toLowerCase().endsWith('.json')) {
|
||||
path += '.json'
|
||||
if (!path.toLowerCase().endsWith(JSON_EXT)) {
|
||||
path += JSON_EXT
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export type WorkflowSuffix = typeof JSON_SUFFIX | typeof APP_JSON_SUFFIX
|
||||
|
||||
export function getWorkflowSuffix(
|
||||
suffix: string | null | undefined
|
||||
): WorkflowSuffix {
|
||||
return suffix === APP_JSON_SUFFIX ? APP_JSON_SUFFIX : JSON_SUFFIX
|
||||
}
|
||||
|
||||
export function appendWorkflowJsonExt(path: string, isApp: boolean): string {
|
||||
return ensureWorkflowSuffix(path, isApp ? APP_JSON_SUFFIX : JSON_SUFFIX)
|
||||
}
|
||||
|
||||
export function ensureWorkflowSuffix(
|
||||
name: string,
|
||||
suffix: WorkflowSuffix
|
||||
): string {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.endsWith(APP_JSON_EXT)) {
|
||||
name = name.slice(0, -APP_JSON_EXT.length)
|
||||
} else if (lower.endsWith(JSON_EXT)) {
|
||||
name = name.slice(0, -JSON_EXT.length)
|
||||
}
|
||||
return name + '.' + suffix
|
||||
}
|
||||
|
||||
export function highlightQuery(
|
||||
text: string,
|
||||
query: string,
|
||||
@@ -96,19 +127,27 @@ export function formatCommitHash(value: string): string {
|
||||
|
||||
/**
|
||||
* Returns various filename components.
|
||||
* Recognises compound extensions like `.app.json`.
|
||||
* Example:
|
||||
* - fullFilename: 'file.txt'
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
* - fullFilename: 'file.txt' → { filename: 'file', suffix: 'txt' }
|
||||
* - fullFilename: 'file.app.json' → { filename: 'file', suffix: 'app.json' }
|
||||
*/
|
||||
export function getFilenameDetails(fullFilename: string) {
|
||||
if (fullFilename.includes('.')) {
|
||||
const lower = fullFilename.toLowerCase()
|
||||
if (
|
||||
lower.endsWith(APP_JSON_EXT) &&
|
||||
fullFilename.length > APP_JSON_EXT.length
|
||||
) {
|
||||
return {
|
||||
filename: fullFilename.split('.').slice(0, -1).join('.'),
|
||||
suffix: fullFilename.split('.').pop() ?? null
|
||||
filename: fullFilename.slice(0, -APP_JSON_EXT.length),
|
||||
suffix: APP_JSON_SUFFIX
|
||||
}
|
||||
} else {
|
||||
return { filename: fullFilename, suffix: null }
|
||||
}
|
||||
const dotIndex = fullFilename.lastIndexOf('.')
|
||||
if (dotIndex <= 0) return { filename: fullFilename, suffix: null }
|
||||
return {
|
||||
filename: fullFilename.slice(0, dotIndex),
|
||||
suffix: fullFilename.slice(dotIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
pnpm-lock.yaml
generated
@@ -180,6 +180,9 @@ catalogs:
|
||||
eslint-import-resolver-typescript:
|
||||
specifier: ^4.4.4
|
||||
version: 4.4.4
|
||||
eslint-plugin-better-tailwindcss:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
eslint-plugin-import-x:
|
||||
specifier: ^4.16.1
|
||||
version: 4.16.1
|
||||
@@ -636,6 +639,9 @@ importers:
|
||||
eslint-import-resolver-typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-better-tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
|
||||
eslint-plugin-import-x:
|
||||
specifier: 'catalog:'
|
||||
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
|
||||
@@ -1916,6 +1922,10 @@ packages:
|
||||
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/css-tree@3.6.9':
|
||||
resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
'@eslint/eslintrc@3.3.3':
|
||||
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -5314,6 +5324,19 @@ packages:
|
||||
eslint-import-resolver-webpack:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-better-tailwindcss@4.3.1:
|
||||
resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
oxlint: ^1.35.0
|
||||
tailwindcss: ^3.3.0 || ^4.1.17
|
||||
peerDependenciesMeta:
|
||||
eslint:
|
||||
optional: true
|
||||
oxlint:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-import-x@4.16.1:
|
||||
resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -6558,6 +6581,9 @@ packages:
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
mdn-data@2.23.0:
|
||||
resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
@@ -7780,6 +7806,10 @@ packages:
|
||||
resolution: {integrity: sha512-2SG1TnJGjMkD4+gblONMGYSrwAzYi+ymOitD+Jb/iMYm57nH20PlkVeMQRah3yDMKEa0QQYUF/QPWpdW7C6zNg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
synckit@0.11.12:
|
||||
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
table@6.9.0:
|
||||
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -7788,6 +7818,10 @@ packages:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tailwind-csstree@0.1.4:
|
||||
resolution: {integrity: sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
@@ -7918,6 +7952,10 @@ packages:
|
||||
ts-map@1.0.3:
|
||||
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
|
||||
|
||||
tsconfig-paths-webpack-plugin@4.2.0:
|
||||
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
@@ -9708,6 +9746,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/css-tree@3.6.9':
|
||||
dependencies:
|
||||
mdn-data: 2.23.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@eslint/eslintrc@3.3.3':
|
||||
dependencies:
|
||||
ajv: 6.14.0
|
||||
@@ -13457,6 +13500,23 @@ snapshots:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@eslint/css-tree': 3.6.9
|
||||
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
|
||||
enhanced-resolve: 5.19.0
|
||||
jiti: 2.6.1
|
||||
synckit: 0.11.12
|
||||
tailwind-csstree: 0.1.4
|
||||
tailwindcss: 4.2.0
|
||||
tsconfig-paths-webpack-plugin: 4.2.0
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
@@ -14893,6 +14953,8 @@ snapshots:
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mdn-data@2.23.0: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-encoder-host-broker@8.0.19:
|
||||
@@ -16571,6 +16633,10 @@ snapshots:
|
||||
'@pkgr/core': 0.2.9
|
||||
tslib: 2.8.1
|
||||
|
||||
synckit@0.11.12:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.9
|
||||
|
||||
table@6.9.0:
|
||||
dependencies:
|
||||
ajv: 8.18.0
|
||||
@@ -16581,6 +16647,8 @@ snapshots:
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-csstree@0.1.4: {}
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss-primeui@0.6.1(tailwindcss@4.2.0):
|
||||
@@ -16691,6 +16759,13 @@ snapshots:
|
||||
|
||||
ts-map@1.0.3: {}
|
||||
|
||||
tsconfig-paths-webpack-plugin@4.2.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
enhanced-resolve: 5.19.0
|
||||
tapable: 2.3.0
|
||||
tsconfig-paths: 4.2.0
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
dependencies:
|
||||
'@types/json5': 0.0.29
|
||||
|
||||
@@ -61,6 +61,7 @@ catalog:
|
||||
eslint: ^9.39.1
|
||||
eslint-config-prettier: ^10.1.8
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.25.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-tl-lg rounded-tr-lg ',
|
||||
'rounded-tl-lg rounded-tr-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
@@ -155,7 +155,7 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const { isSelectMode } = useAppMode()
|
||||
const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
@@ -163,7 +163,9 @@ const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
const sidebarPanelVisible = computed(
|
||||
() => activeSidebarTab.value !== null && !isBuilderMode.value
|
||||
)
|
||||
|
||||
const sidebarStateKey = computed(() => {
|
||||
return unifiedWidth.value
|
||||
|
||||
@@ -296,11 +296,13 @@ describe('TopMenuSection', () => {
|
||||
describe('inline progress summary', () => {
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
qpoV2Enabled: boolean
|
||||
qpoV2Enabled: boolean,
|
||||
showRunProgressBar = true
|
||||
) => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
return undefined
|
||||
})
|
||||
@@ -332,6 +334,19 @@ describe('TopMenuSection', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render inline progress summary when run progress bar is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('teleports inline progress summary when actionbar is floating', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
const actionbarTarget = document.createElement('div')
|
||||
|
||||
@@ -125,6 +125,7 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -164,14 +165,16 @@ const isActionbarFloating = computed(
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
() => !isQueuePanelV2Enabled.value
|
||||
)
|
||||
const shouldShowInlineProgressSummary = computed(
|
||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||
() =>
|
||||
isQueuePanelV2Enabled.value &&
|
||||
isActionbarEnabled.value &&
|
||||
isRunProgressBarEnabled.value
|
||||
)
|
||||
const shouldShowQueueNotificationBanners = computed(
|
||||
() => isActionbarEnabled.value
|
||||
|
||||
101
src/components/actionbar/ComfyActionbar.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
showRunProgressBar: boolean
|
||||
) => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
if (key === 'Comfy.Queue.QPOV2') return true
|
||||
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
const mountActionbar = (showRunProgressBar: boolean) => {
|
||||
const topMenuContainer = document.createElement('div')
|
||||
document.body.appendChild(topMenuContainer)
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, showRunProgressBar)
|
||||
|
||||
const wrapper = mount(ComfyActionbar, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
topMenuContainer,
|
||||
queueOverlayExpanded: false
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
template: '<div />'
|
||||
},
|
||||
Panel: {
|
||||
name: 'Panel',
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
StatusBadge: true,
|
||||
ComfyRunButton: {
|
||||
name: 'ComfyRunButton',
|
||||
template: '<button type="button">Run</button>'
|
||||
},
|
||||
QueueInlineProgress: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
topMenuContainer
|
||||
}
|
||||
}
|
||||
|
||||
describe('ComfyActionbar', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('teleports inline progress when run progress bar is enabled', async () => {
|
||||
const { wrapper, topMenuContainer } = mountActionbar(true)
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
|
||||
).not.toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
topMenuContainer.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not teleport inline progress when run progress bar is disabled', async () => {
|
||||
const { wrapper, topMenuContainer } = mountActionbar(false)
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
|
||||
).toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
topMenuContainer.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -127,7 +128,7 @@ const emit = defineEmits<{
|
||||
(event: 'update:progressTarget', target: HTMLElement | null): void
|
||||
}>()
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -137,11 +138,10 @@ const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingsStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
|
||||
const panelRef = ref<ComponentPublicInstance | null>(null)
|
||||
const panelElement = computed<HTMLElement | null>(() => {
|
||||
@@ -325,7 +325,13 @@ const onMouseLeaveDropZone = () => {
|
||||
}
|
||||
|
||||
const inlineProgressTarget = computed(() => {
|
||||
if (!visible.value || !isQueuePanelV2Enabled.value) return null
|
||||
if (
|
||||
!visible.value ||
|
||||
!isQueuePanelV2Enabled.value ||
|
||||
!isRunProgressBarEnabled.value
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (isDocked.value) return topMenuContainer ?? null
|
||||
return panelElement.value
|
||||
})
|
||||
@@ -361,7 +367,13 @@ const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
buildTooltipConfig(
|
||||
t(
|
||||
isQueuePanelV2Enabled.value
|
||||
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
|
||||
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
|
||||
)
|
||||
)
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
|
||||
@@ -9,30 +9,28 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder, setMode } = useAppMode()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const { enterBuilder } = useAppModeStore()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isWorkflowsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
|
||||
function openTemplates() {
|
||||
@@ -43,7 +41,7 @@ function openTemplates() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 pointer-events-auto">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar">
|
||||
<template #button>
|
||||
<template #button="{ hasUnseenItems }">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
@@ -52,10 +50,15 @@ function openTemplates() {
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.labels.menu')"
|
||||
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</WorkflowActionsDropdown>
|
||||
@@ -70,7 +73,7 @@ function openTemplates() {
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilderMode"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
@@ -101,9 +104,7 @@ function openTemplates() {
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
|
||||
@@ -60,6 +60,7 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -70,7 +71,6 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
@@ -107,9 +107,10 @@ const rename = async (
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
} else if (workflowStore.activeWorkflow) {
|
||||
try {
|
||||
const suffix = getWorkflowSuffix(workflowStore.activeWorkflow.suffix)
|
||||
await workflowService.renameWorkflow(
|
||||
workflowStore.activeWorkflow,
|
||||
ComfyWorkflow.basePath + appendJsonExt(newName)
|
||||
ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, provide, ref, toValue, watchEffect } from 'vue'
|
||||
import { computed, provide, ref, toValue } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -38,31 +39,20 @@ const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { mode, isArrangeMode } = useAppMode()
|
||||
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
|
||||
useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
// Prune stale entries whose node/widget no longer exists, so the
|
||||
// DraggableList model always matches the rendered items.
|
||||
watchEffect(() => {
|
||||
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
return node?.widgets?.some((w) => w.name === widgetName)
|
||||
})
|
||||
if (valid.length < appModeStore.selectedInputs.length) {
|
||||
appModeStore.selectedInputs = valid
|
||||
}
|
||||
})
|
||||
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return null
|
||||
const node = resolveNode(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
return { nodeId, widgetName, node, widget }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
@@ -70,9 +60,15 @@ const arrangeInputs = computed(() =>
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return { nodeId, widgetName }
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
subLabel: t('linearMode.builder.unknownWidget')
|
||||
}
|
||||
}
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
@@ -166,16 +162,18 @@ function handleClick(e: MouseEvent) {
|
||||
if (!node) return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
if (!isSelectOutputsMode.value) return
|
||||
if (!node.constructor.nodeData?.output_node)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
|
||||
if (index === -1) appModeStore.selectedOutputs.push(node.id)
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
if (!isSelectInputsMode.value) return
|
||||
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
|
||||
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
@@ -220,9 +218,9 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
|
||||
:aria-label="`${widget.label ?? widgetName} — ${node.title}`"
|
||||
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<div v-if="widget" class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
@@ -230,15 +228,22 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground text-sm p-1 pointer-events-none">
|
||||
{{ widgetName }}
|
||||
<p class="text-xs italic">
|
||||
({{ t('linearMode.builder.unknownWidget') }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-else
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-border-subtle border-b"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
@@ -274,18 +279,19 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId === id && widgetName === name
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="!isArrangeMode"
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
@@ -319,12 +325,15 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<Teleport v-if="mode === 'builder:select'" to="body">
|
||||
<Teleport
|
||||
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
||||
to="body"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -338,36 +347,46 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<TransformPane :canvas="canvasStore.getCanvas()">
|
||||
<div
|
||||
v-for="[key, style] in renderedInputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
class="fixed bg-primary-background/30 rounded-lg"
|
||||
/>
|
||||
<div
|
||||
v-for="[key, style, isSelected] in renderedOutputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
:class="
|
||||
cn(
|
||||
'fixed ring-warning-background ring-5 rounded-2xl',
|
||||
!isSelected && 'ring-warning-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="absolute top-0 right-0 size-8">
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
|
||||
>
|
||||
<i class="icon-[lucide--check] bg-text-foreground size-full" />
|
||||
<template v-if="isSelectInputsMode">
|
||||
<div
|
||||
v-for="[key, style] in renderedInputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
class="fixed bg-primary-background/30 rounded-lg"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="[key, style, isSelected] in renderedOutputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
:class="
|
||||
cn(
|
||||
'fixed ring-warning-background ring-5 rounded-2xl',
|
||||
!isSelected && 'ring-warning-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="absolute top-0 right-0 size-8">
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
|
||||
@click.stop="
|
||||
remove(appModeStore.selectedOutputs, (k) => k == key)
|
||||
"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<i class="icon-[lucide--check] bg-text-foreground size-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
|
||||
@click.stop="appModeStore.selectedOutputs.push(key)"
|
||||
@pointerdown.stop
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TransformPane>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-check-big] size-4 text-green-500"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppBody')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphBody')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppPrompt')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="appliedAsApp">
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
appliedAsApp: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
viewApp: []
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
|
||||
<div
|
||||
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
@@ -11,6 +13,7 @@
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showClose"
|
||||
variant="muted-textonly"
|
||||
class="-mr-1"
|
||||
:aria-label="$t('g.close')"
|
||||
@@ -21,7 +24,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-1 flex-col gap-4 px-4 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -35,6 +38,10 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { showClose = true } = defineProps<{
|
||||
showClose?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
147
src/components/builder/BuilderFooterToolbar.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
|
||||
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockState = {
|
||||
mode: 'builder:select' as AppMode,
|
||||
settingView: false
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: computed(() => mockState.mode),
|
||||
isBuilderMode: ref(true),
|
||||
setMode: mockSetMode
|
||||
})
|
||||
}))
|
||||
|
||||
const mockHasOutputs = ref(true)
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
exitBuilder: mockExitBuilder,
|
||||
hasOutputs: mockHasOutputs,
|
||||
$id: 'appMode'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
dialogStack: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
|
||||
useAppSetDefaultView: () => ({
|
||||
settingView: computed(() => mockState.settingView),
|
||||
showDialog: mockShowDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
builderMenu: { exitAppBuilder: 'Exit app builder' },
|
||||
g: { back: 'Back', next: 'Next' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('BuilderFooterToolbar', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockState.mode = 'builder:inputs'
|
||||
mockHasOutputs.value = true
|
||||
mockState.settingView = false
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
return mount(BuilderFooterToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
|
||||
const buttons = wrapper.findAll('button')
|
||||
return {
|
||||
exit: buttons[0],
|
||||
back: buttons[1],
|
||||
next: buttons[2]
|
||||
}
|
||||
}
|
||||
|
||||
it('disables back on the first step', () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { back } = getButtons(mountComponent())
|
||||
expect(back.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables back on the second step', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { back } = getButtons(mountComponent())
|
||||
expect(back.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables next on the setDefaultView step', () => {
|
||||
mockState.settingView = true
|
||||
const { next } = getButtons(mountComponent())
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables next on arrange step when no outputs', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
mockHasOutputs.value = false
|
||||
const { next } = getButtons(mountComponent())
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables next on inputs step', () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { next } = getButtons(mountComponent())
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls setMode on back click', async () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { back } = getButtons(mountComponent())
|
||||
await back.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('calls setMode on next click from inputs step', async () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { next } = getButtons(mountComponent())
|
||||
await next.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('opens default view dialog on next click from arrange step', async () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { next } = getButtons(mountComponent())
|
||||
await next.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
|
||||
expect(mockShowDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls exitBuilder on exit button click', async () => {
|
||||
const { exit } = getButtons(mountComponent())
|
||||
await exit.trigger('click')
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
<nav
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<Button size="lg" @click="onExitBuilder">
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -17,10 +31,16 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
|
||||
hasOutputs
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -32,7 +32,7 @@
|
||||
@click="onSave(close)"
|
||||
>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
{{ t('builderMenu.saveApp') }}
|
||||
{{ t('g.save') }}
|
||||
</button>
|
||||
<div class="my-1 border-t border-border-default" />
|
||||
<button
|
||||
@@ -51,19 +51,28 @@ import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { setSaving } = useBuilderSave()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
function onSave(close: () => void) {
|
||||
setSaving(true)
|
||||
close()
|
||||
async function onSave(close: () => void) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
try {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
close()
|
||||
} catch (error) {
|
||||
toastErrorHandler(error)
|
||||
}
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveSuccess') }}
|
||||
</template>
|
||||
|
||||
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
|
||||
</p>
|
||||
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
|
||||
size="lg"
|
||||
@click="onClose"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="savedAsApp && onViewApp"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="onViewApp"
|
||||
>
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
workflowName: string
|
||||
savedAsApp: boolean
|
||||
onViewApp?: () => void
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -6,21 +6,18 @@
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<template
|
||||
v-for="(step, index) in [selectStep, arrangeStep]"
|
||||
:key="step.id"
|
||||
>
|
||||
<template v-for="(step, index) in steps" :key="step.id">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === step.id && 'bg-interface-builder-mode-background',
|
||||
activeStep !== step.id &&
|
||||
'hover:bg-secondary-background bg-transparent'
|
||||
activeStep === step.id
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="setMode(step.id)"
|
||||
@click="navigateToStep(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
@@ -29,15 +26,19 @@
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Save -->
|
||||
<!-- Default view -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="setMode('builder:select')"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
@@ -45,64 +46,72 @@
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'save'
|
||||
activeStep === 'setDefaultView'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="setSaving(true)"
|
||||
@click="navigateToStep('setDefaultView')"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { BuilderStepId } from './useBuilderSteps'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { mode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { saving, setSaving } = useBuilderSave()
|
||||
|
||||
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
const selectStep: BuilderToolbarStep<AppMode> = {
|
||||
id: 'builder:select',
|
||||
title: t('builderToolbar.select'),
|
||||
subtitle: t('builderToolbar.selectDescription'),
|
||||
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'builder:inputs',
|
||||
title: t('builderToolbar.inputs'),
|
||||
subtitle: t('builderToolbar.inputsDescription'),
|
||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||
}
|
||||
|
||||
const arrangeStep: BuilderToolbarStep<AppMode> = {
|
||||
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'builder:outputs',
|
||||
title: t('builderToolbar.outputs'),
|
||||
subtitle: t('builderToolbar.outputsDescription'),
|
||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||
}
|
||||
|
||||
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'builder:arrange',
|
||||
title: t('builderToolbar.arrange'),
|
||||
subtitle: t('builderToolbar.arrangeDescription'),
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const saveStep: BuilderToolbarStep<'save'> = {
|
||||
id: 'save',
|
||||
title: t('builderToolbar.save'),
|
||||
subtitle: t('builderToolbar.saveDescription'),
|
||||
icon: 'icon-[lucide--cloud-upload]'
|
||||
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'setDefaultView',
|
||||
title: t('builderToolbar.defaultView'),
|
||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||
icon: 'icon-[lucide--eye]'
|
||||
}
|
||||
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
|
||||
</script>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</PopoverClose>
|
||||
<PopoverClose as-child>
|
||||
<Button variant="secondary" size="md" @click="emit('switch')">
|
||||
{{ t('builderToolbar.switchToSelect') }}
|
||||
{{ t('builderToolbar.switchToOutputs') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
{{ $t('builderToolbar.defaultViewTitle') }}
|
||||
</template>
|
||||
|
||||
<!-- Filename -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :for="inputId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.filename') }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="filename"
|
||||
autofocus
|
||||
type="text"
|
||||
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
|
||||
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save as type -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveAsLabel') }}
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in saveTypeOptions"
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
@@ -61,23 +45,18 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="onClose">
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="onSave(filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
|
||||
{{ $t('g.apply') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -87,17 +66,18 @@ import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { defaultFilename, onSave, onClose } = defineProps<{
|
||||
defaultFilename: string
|
||||
onSave: (filename: string, openAsApp: boolean) => void
|
||||
onClose: () => void
|
||||
const { initialOpenAsApp = true } = defineProps<{
|
||||
initialOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(true)
|
||||
defineEmits<{
|
||||
apply: [openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const saveTypeOptions = [
|
||||
const openAsApp = ref(initialOpenAsApp)
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
40
src/components/builder/EmptyWorkflowDialogContent.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<BuilderDialog :show-close="false">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.emptyWorkflowTitle') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
@click="$emit('backToWorkflow')"
|
||||
>
|
||||
{{ $t('builderToolbar.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
|
||||
{{ $t('builderToolbar.loadTemplate') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineEmits<{
|
||||
backToWorkflow: []
|
||||
loadTemplate: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -32,9 +32,15 @@ const entries = computed(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-2 my-2 rounded-lg flex items-center-safe">
|
||||
<span class="mr-auto" v-text="title" />
|
||||
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
|
||||
<div class="p-2 my-2 rounded-lg flex items-center-safe gap-2">
|
||||
<div
|
||||
class="mr-auto flex-[4_1_0%] max-w-max min-w-0 truncate drag-handle inline"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
class="flex-[2_1_0%] max-w-max min-w-0 truncate text-muted-foreground text-end drag-handle inline"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
|
||||
222
src/components/builder/useAppSetDefaultView.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn(),
|
||||
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: null as {
|
||||
initialMode?: string | null
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
rootGraph: { extra: {} as Record<string, unknown> }
|
||||
}))
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('./DefaultViewDialogContent.vue', () => ({
|
||||
default: { name: 'MockDefaultViewDialogContent' }
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
|
||||
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
|
||||
}))
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
describe('useAppSetDefaultView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockApp.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
describe('settingView', () => {
|
||||
it('reflects dialogStore.isDialogOpen', () => {
|
||||
mockDialogStore.isDialogOpen.mockReturnValue(true)
|
||||
const { settingView } = useAppSetDefaultView()
|
||||
expect(settingView.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDialog', () => {
|
||||
it('opens dialog via dialogService', () => {
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when initialMode is not graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp false when initialMode is graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(false)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when no active workflow', () => {
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleApply', () => {
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('closes dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.key).toBe('builder-default-view-applied')
|
||||
expect(confirmCall.props.appliedAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes appliedAsApp false to confirmation dialog when graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.props.appliedAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applied dialog', () => {
|
||||
function applyAndGetConfirmDialog(openAsApp: boolean) {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
applyCall.props.onApply(openAsApp)
|
||||
|
||||
return mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
}
|
||||
|
||||
it('onViewApp sets mode to app and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onViewApp()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onClose closes confirmation dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
|
||||
mockDialogStore.closeDialog.mockClear()
|
||||
confirmCall.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
71
src/components/builder/useAppSetDefaultView.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
|
||||
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
|
||||
|
||||
const DIALOG_KEY = 'builder-default-view'
|
||||
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
|
||||
|
||||
export function useAppSetDefaultView() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
|
||||
|
||||
function showDialog() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: DefaultViewDialogContent,
|
||||
props: {
|
||||
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
|
||||
onApply: handleApply,
|
||||
onClose: closeDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleApply(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
function showAppliedDialog(appliedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: APPLIED_DIALOG_KEY,
|
||||
component: BuilderDefaultModeAppliedDialogContent,
|
||||
props: {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
setMode('app')
|
||||
},
|
||||
onClose: closeAppliedDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function closeAppliedDialog() {
|
||||
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
|
||||
}
|
||||
|
||||
return { settingView, showDialog }
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const { setMode } = useAppMode()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
whenever(saving, onBuilderSave)
|
||||
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) {
|
||||
resetSaving()
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflow.isTemporary && workflow.initialMode != null) {
|
||||
// Re-save with the previously chosen mode — no dialog needed.
|
||||
try {
|
||||
appModeStore.flushSelections()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
resetSaving()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
showSaveDialog(workflow.filename)
|
||||
}
|
||||
|
||||
function showSaveDialog(defaultFilename: string) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: SAVE_DIALOG_KEY,
|
||||
component: BuilderSaveDialogContent,
|
||||
props: {
|
||||
defaultFilename,
|
||||
onSave: handleSave,
|
||||
onClose: handleCancelSave
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: resetSaving
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancelSave() {
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
|
||||
async function handleSave(filename: string, openAsApp: boolean) {
|
||||
try {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
appModeStore.flushSelections()
|
||||
const mode = openAsApp ? 'app' : 'graph'
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
initialMode: mode
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
component: BuilderSaveSuccessDialogContent,
|
||||
props: {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
setMode('app')
|
||||
closeSuccessDialog()
|
||||
},
|
||||
onClose: closeSuccessDialog
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: resetSaving
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeSaveDialog() {
|
||||
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
|
||||
}
|
||||
|
||||
function closeSuccessDialog() {
|
||||
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
|
||||
resetSaving()
|
||||
}
|
||||
|
||||
function resetSaving() {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
return { saving, setSaving }
|
||||
}
|
||||
79
src/components/builder/useBuilderSteps.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
const BUILDER_STEPS = [
|
||||
'builder:inputs',
|
||||
'builder:outputs',
|
||||
'builder:arrange',
|
||||
'setDefaultView'
|
||||
] as const
|
||||
|
||||
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
|
||||
|
||||
const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
|
||||
|
||||
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
const { mode, isBuilderMode, setMode } = useAppMode()
|
||||
const { settingView, showDialog } = useAppSetDefaultView()
|
||||
|
||||
const activeStep = computed<BuilderStepId>(() => {
|
||||
if (settingView.value) return 'setDefaultView'
|
||||
if (isBuilderMode.value) {
|
||||
return mode.value as BuilderStepId
|
||||
}
|
||||
return 'builder:inputs'
|
||||
})
|
||||
|
||||
const activeStepIndex = computed(() =>
|
||||
BUILDER_STEPS.indexOf(activeStep.value)
|
||||
)
|
||||
|
||||
const isFirstStep = computed(() => activeStepIndex.value === 0)
|
||||
|
||||
const isLastStep = computed(() => {
|
||||
if (!options?.hasOutputs?.value)
|
||||
return activeStepIndex.value >= ARRANGE_INDEX
|
||||
return activeStepIndex.value >= BUILDER_STEPS.length - 1
|
||||
})
|
||||
|
||||
const isSelectStep = computed(
|
||||
() =>
|
||||
activeStep.value === 'builder:inputs' ||
|
||||
activeStep.value === 'builder:outputs'
|
||||
)
|
||||
|
||||
function navigateToStep(stepId: BuilderStepId) {
|
||||
if (stepId === 'setDefaultView') {
|
||||
setMode('builder:arrange')
|
||||
showDialog()
|
||||
} else {
|
||||
setMode(stepId)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (isFirstStep.value) return
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (isLastStep.value) return
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
}
|
||||
|
||||
return {
|
||||
activeStep,
|
||||
activeStepIndex,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
goBack,
|
||||
goNext
|
||||
}
|
||||
}
|
||||
44
src/components/builder/useEmptyWorkflowDialog.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import EmptyWorkflowDialogContent from './EmptyWorkflowDialogContent.vue'
|
||||
|
||||
const DIALOG_KEY = 'builder-empty-workflow'
|
||||
|
||||
export function useEmptyWorkflowDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
|
||||
function show(options: {
|
||||
onEnterBuilder: () => void
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: EmptyWorkflowDialogContent,
|
||||
props: {
|
||||
onBackToWorkflow: () => {
|
||||
closeDialog()
|
||||
options.onDismiss()
|
||||
},
|
||||
onLoadTemplate: () => {
|
||||
closeDialog()
|
||||
templateSelectorDialog.show('appbuilder', {
|
||||
afterClose: () => {
|
||||
if (app.rootGraph?.nodes?.length) options.onEnterBuilder()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
return { show }
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -88,6 +89,7 @@ const handleFileUpload = async (event: Event) => {
|
||||
type: 'input',
|
||||
subfolder: 'backgrounds'
|
||||
})
|
||||
appendCloudResParam(params, file.name)
|
||||
modelValue.value = `/api/view?${params.toString()}`
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<span
|
||||
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
|
||||
:class="textColorClass"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
|
||||
textColorClass
|
||||
)
|
||||
"
|
||||
:style="customStyle"
|
||||
>
|
||||
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
|
||||
|
||||
65
src/components/common/DropdownItem.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
v-if="item.separator"
|
||||
class="h-[1px] bg-border-subtle m-1"
|
||||
/>
|
||||
<DropdownMenuSub v-else-if="item.items">
|
||||
<DropdownMenuSubTrigger
|
||||
:class="itemClass"
|
||||
:disabled="toValue(item.disabled) ?? !item.items?.length"
|
||||
>
|
||||
{{ item.label }}
|
||||
<i class="ml-auto icon-[lucide--chevron-right]" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent
|
||||
:class="contentClass"
|
||||
:side-offset="2"
|
||||
:align-offset="-5"
|
||||
>
|
||||
<DropdownItem
|
||||
v-for="(subitem, index) in item.items"
|
||||
:key="toValue(subitem.label) ?? index"
|
||||
:item="subitem"
|
||||
:item-class
|
||||
:content-class
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto bg-primary-background rounded-full text-xxs font-bold px-1 flex leading-none items-center"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
74
src/components/common/DropdownMenu.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
itemClass?: string
|
||||
contentClass?: string
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'data-[highlighted]:bg-secondary-background-hover data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground flex p-2 leading-none rounded-lg gap-1 cursor-pointer m-1',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'z-1700 rounded-lg p-2 bg-base-background border border-border-subtle min-w-[220px] shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button size="icon">
|
||||
<i :class="icon ?? 'icon-[lucide--menu]'" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal :to>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
v-for="(item, index) in entries ?? []"
|
||||
:key="toValue(item.label) ?? index"
|
||||
:item-class
|
||||
:content-class
|
||||
:item
|
||||
/>
|
||||
</slot>
|
||||
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -3,14 +3,18 @@
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center">
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
|
||||
<h3>{{ title }}</h3>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="icon"
|
||||
style="font-size: 3rem; margin-bottom: 1rem"
|
||||
/>
|
||||
<h3 v-if="title">{{ title }}</h3>
|
||||
<p :class="textClass" class="text-center whitespace-pre-line">
|
||||
{{ message }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="buttonLabel"
|
||||
variant="textonly"
|
||||
:variant="buttonVariant ?? 'textonly'"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
@@ -25,14 +29,16 @@
|
||||
import Card from 'primevue/card'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
icon?: string
|
||||
title: string
|
||||
title?: string
|
||||
message: string
|
||||
textClass?: string
|
||||
buttonLabel?: string
|
||||
buttonVariant?: ButtonVariants['variant']
|
||||
}>()
|
||||
|
||||
defineEmits(['action'])
|
||||
@@ -51,7 +57,6 @@ defineEmits(['action'])
|
||||
}
|
||||
|
||||
.no-results-placeholder p {
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="h-full aspect-8/7 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@@ -53,7 +53,7 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="h-full aspect-8/7 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
|
||||
@@ -11,17 +13,17 @@ const {
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
}>()
|
||||
|
||||
const badgeClass = computed(() =>
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
"
|
||||
>
|
||||
<span :class="badgeClass">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import type { FlattenedItem } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -9,12 +10,25 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue('left')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
useNodeBookmarkStore: () => ({
|
||||
isBookmarked: vi.fn().mockReturnValue(false),
|
||||
toggleBookmark: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
@@ -78,6 +92,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
return {
|
||||
wrapper: mount(TreeExplorerV2Node, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
Teleport: { template: '<div />' }
|
||||
|
||||
@@ -24,6 +24,25 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
|
||||
'opacity-0 group-hover/tree-node:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
@@ -33,6 +52,15 @@
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i
|
||||
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
|
||||
/>
|
||||
@@ -41,15 +69,6 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</TreeItem>
|
||||
|
||||
@@ -73,6 +92,7 @@ import { computed, inject } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -93,9 +113,21 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const contextMenuNode = inject(InjectKeyContextMenuNode)
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const nodeDef = computed(() => item.value.data)
|
||||
|
||||
const isBookmarked = computed(() => {
|
||||
if (!nodeDef.value) return false
|
||||
return nodeBookmarkStore.isBookmarked(nodeDef.value)
|
||||
})
|
||||
|
||||
function toggleBookmark() {
|
||||
if (nodeDef.value) {
|
||||
nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -27,8 +28,13 @@ const { menuItems } = useWorkflowActionsMenu(
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
})
|
||||
@@ -39,7 +45,7 @@ function handleOpen(open: boolean) {
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
@@ -49,7 +55,7 @@ function handleOpen(open: boolean) {
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
@@ -60,6 +66,11 @@ function handleOpen(open: boolean) {
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
|
||||
describe('WorkflowActionsList', () => {
|
||||
it('renders action items with label and icon', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
|
||||
|
||||
it('renders separator items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ separator: true },
|
||||
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
|
||||
it('dispatches command on select', async () => {
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Action', icon: 'pi pi-play', command }
|
||||
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -57,6 +57,7 @@ describe('WorkflowActionsList', () => {
|
||||
it('renders badge when present', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
id: 'new-feature',
|
||||
label: 'New Feature',
|
||||
icon: 'pi pi-star',
|
||||
command: vi.fn(),
|
||||
@@ -69,9 +70,27 @@ describe('WorkflowActionsList', () => {
|
||||
expect(wrapper.text()).toContain('NEW')
|
||||
})
|
||||
|
||||
it('does not render items with visible set to false', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
id: 'hidden',
|
||||
label: 'Hidden Item',
|
||||
icon: 'pi pi-eye-slash',
|
||||
command: vi.fn(),
|
||||
visible: false
|
||||
},
|
||||
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('Hidden Item')
|
||||
expect(wrapper.text()).toContain('Shown Item')
|
||||
})
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
/>
|
||||
<component
|
||||
:is="itemComponent"
|
||||
v-else
|
||||
v-else-if="item.visible !== false"
|
||||
:disabled="item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -234,6 +234,7 @@ 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 { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -245,6 +246,7 @@ const { missingNodeTypes } = defineProps<{
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const dialogStore = useDialogStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
interface ProcessedNode {
|
||||
label: string
|
||||
@@ -339,6 +341,14 @@ function handleReplaceSelected() {
|
||||
replacedTypes.value = nextReplaced
|
||||
selectedTypes.value = nextSelected
|
||||
|
||||
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
|
||||
// but the modal only updates its own local UI state above.
|
||||
// Without this call the Errors Tab would still list the replaced nodes
|
||||
// as missing because executionErrorStore is not aware of the replacement.
|
||||
if (result.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType(result)
|
||||
}
|
||||
|
||||
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||
const allReplaced = replaceableNodes.value.every((n) =>
|
||||
nextReplaced.has(n.label)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
<TopbarSubscribeButton />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,8 +39,8 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<AppBuilder v-if="mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!isBuilderMode" />
|
||||
<AppBuilder v-if="isBuilderMode" />
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
@@ -140,6 +141,7 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -202,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode, isBuilderMode } = useAppMode()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
116
src/components/graph/widgets/DomWidget.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import type { DomWidgetState } from '@/stores/domWidgetStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
import DomWidget from './DomWidget.vue'
|
||||
|
||||
const mockUpdatePosition = vi.fn()
|
||||
const mockUpdateClipPath = vi.fn()
|
||||
const mockCanvasElement = document.createElement('canvas')
|
||||
const mockCanvasStore = {
|
||||
canvas: {
|
||||
graph: {
|
||||
getNodeById: vi.fn(() => true)
|
||||
},
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
canvas: mockCanvasElement,
|
||||
selected_nodes: {}
|
||||
},
|
||||
getCanvas: () => ({ canvas: mockCanvasElement }),
|
||||
linearMode: false
|
||||
}
|
||||
|
||||
vi.mock('@/composables/element/useAbsolutePosition', () => ({
|
||||
useAbsolutePosition: () => ({
|
||||
style: reactive<Record<string, string>>({}),
|
||||
updatePosition: mockUpdatePosition
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useDomClipping', () => ({
|
||||
useDomClipping: () => ({
|
||||
style: reactive<Record<string, string>>({}),
|
||||
updateClipPath: mockUpdateClipPath
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn(() => false)
|
||||
})
|
||||
}))
|
||||
|
||||
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const node = createMockLGraphNode({
|
||||
id: 1,
|
||||
constructor: {
|
||||
nodeData: {}
|
||||
}
|
||||
})
|
||||
|
||||
const widget = {
|
||||
id: 'dom-widget-id',
|
||||
name: 'test_widget',
|
||||
type: 'custom',
|
||||
value: '',
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: false
|
||||
} as unknown as BaseDOMWidget<object | string>
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: createMockLGraphNode({ id: 2 }),
|
||||
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
|
||||
})
|
||||
|
||||
const state = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!state) throw new Error('Expected registered DomWidgetState')
|
||||
|
||||
state.zIndex = 2
|
||||
state.size = [100, 40]
|
||||
|
||||
return reactive(state)
|
||||
}
|
||||
|
||||
describe('DomWidget disabled style', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useDomWidgetStore().clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||
const widgetState = createWidgetState(true)
|
||||
const wrapper = mount(DomWidget, {
|
||||
props: {
|
||||
widgetState
|
||||
}
|
||||
})
|
||||
|
||||
widgetState.zIndex = 3
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const root = wrapper.get('.dom-widget').element as HTMLElement
|
||||
expect(root.style.pointerEvents).toBe('none')
|
||||
expect(root.style.opacity).toBe('0.5')
|
||||
})
|
||||
})
|
||||
@@ -110,13 +110,17 @@ watch(
|
||||
updateDomClipping()
|
||||
}
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const isDisabled = override
|
||||
? (override.widget.computedDisabled ?? widget.computedDisabled)
|
||||
: widget.computedDisabled
|
||||
|
||||
style.value = {
|
||||
...positionStyle.value,
|
||||
...(enableDomClipping.value ? clippingStyle.value : {}),
|
||||
zIndex: widgetState.zIndex,
|
||||
pointerEvents:
|
||||
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
|
||||
opacity: widget.computedDisabled ? 0.5 : 1
|
||||
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
|
||||
opacity: isDisabled ? 0.5 : 1
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-(--base-background) border border-(--border-default)"
|
||||
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div ref="previewWrapperRef" class="origin-top-left scale-50">
|
||||
@@ -100,7 +100,7 @@ import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphN
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const SCALE_FACTOR = 0.5
|
||||
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
|
||||
const PREVIEW_CONTAINER_PADDING_PX = 24
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
/>
|
||||
<div
|
||||
v-show="cursorVisible"
|
||||
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
|
||||
:style="cursorStyle"
|
||||
ref="cursorEl"
|
||||
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)] will-change-transform"
|
||||
:style="cursorSizeStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +142,7 @@
|
||||
max="100"
|
||||
step="1"
|
||||
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
@click.prevent
|
||||
@click.stop
|
||||
@change="
|
||||
(e) => {
|
||||
const val = Math.min(
|
||||
@@ -281,6 +282,7 @@ const { nodeId } = defineProps<{
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const cursorEl = useTemplateRef<HTMLElement>('cursorEl')
|
||||
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
|
||||
const { width: controlsWidth } = useElementSize(controlsEl)
|
||||
const compact = computed(
|
||||
@@ -296,8 +298,6 @@ const {
|
||||
backgroundColor,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
cursorX,
|
||||
cursorY,
|
||||
cursorVisible,
|
||||
displayBrushSize,
|
||||
inputImageUrl,
|
||||
@@ -309,7 +309,7 @@ const {
|
||||
handlePointerLeave,
|
||||
handleInputImageLoad,
|
||||
handleClear
|
||||
} = usePainter(nodeId, { canvasEl, modelValue })
|
||||
} = usePainter(nodeId, { canvasEl, cursorEl, modelValue })
|
||||
|
||||
const canvasContainerStyle = computed(() => ({
|
||||
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
|
||||
@@ -318,16 +318,10 @@ const canvasContainerStyle = computed(() => ({
|
||||
: backgroundColor.value
|
||||
}))
|
||||
|
||||
const cursorStyle = computed(() => {
|
||||
const size = displayBrushSize.value
|
||||
const x = cursorX.value - size / 2
|
||||
const y = cursorY.value - size / 2
|
||||
return {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
transform: `translate(${x}px, ${y}px)`
|
||||
}
|
||||
})
|
||||
const cursorSizeStyle = computed(() => ({
|
||||
width: `${displayBrushSize.value}px`,
|
||||
height: `${displayBrushSize.value}px`
|
||||
}))
|
||||
|
||||
const brushOpacityPercent = computed({
|
||||
get: () => Math.round(brushOpacity.value * 100),
|
||||
|
||||
123
src/components/queue/JobHistoryActionsMenu.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
|
||||
const popoverCloseSpy = vi.fn()
|
||||
|
||||
vi.mock('@/components/ui/Popover.vue', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('div', [
|
||||
slots.button?.(),
|
||||
slots.default?.({
|
||||
close: () => {
|
||||
popoverCloseSpy()
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||
? true
|
||||
: undefined
|
||||
)
|
||||
const mockSetSetting = vi.fn()
|
||||
const mockSetMany = vi.fn()
|
||||
const mockSidebarTabStore = {
|
||||
activeSidebarTabId: null as string | null
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockGetSetting,
|
||||
set: mockSetSetting,
|
||||
setMany: mockSetMany
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => mockSidebarTabStore
|
||||
}))
|
||||
|
||||
const mountMenu = () =>
|
||||
mount(JobHistoryActionsMenu, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobHistoryActionsMenu', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
popoverCloseSpy.mockClear()
|
||||
mockSetSetting.mockClear()
|
||||
mockSetMany.mockClear()
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||
? true
|
||||
: undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles show run progress bar setting from the menu', async () => {
|
||||
const wrapper = mountMenu()
|
||||
|
||||
const showRunProgressBarButton = wrapper.get(
|
||||
'[data-testid="show-run-progress-bar-action"]'
|
||||
)
|
||||
await showRunProgressBarButton.trigger('click')
|
||||
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith(
|
||||
'Comfy.Queue.ShowRunProgressBar',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('opens docked job history sidebar when enabling from the menu', async () => {
|
||||
mockGetSetting.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return false
|
||||
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
|
||||
return undefined
|
||||
})
|
||||
const wrapper = mountMenu()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||
expect(mockSetMany).not.toHaveBeenCalled()
|
||||
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
})
|
||||
|
||||
it('emits clear history from the menu', async () => {
|
||||
const wrapper = mountMenu()
|
||||
|
||||
const clearHistoryButton = wrapper.get(
|
||||
'[data-testid="clear-history-action"]'
|
||||
)
|
||||
await clearHistoryButton.trigger('click')
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@
|
||||
data-testid="docked-job-history-action"
|
||||
class="w-full justify-between text-sm font-light"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="onToggleDockedJobHistory(close)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -35,14 +35,32 @@
|
||||
class="icon-[lucide--check] size-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="show-run-progress-bar-action"
|
||||
class="w-full justify-between text-sm font-light"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click="onToggleRunProgressBar"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
|
||||
}}</span>
|
||||
</span>
|
||||
<i
|
||||
v-if="isRunProgressBarEnabled"
|
||||
class="icon-[lucide--check] size-4"
|
||||
/>
|
||||
</Button>
|
||||
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
|
||||
<template v-if="showClearHistoryAction">
|
||||
<div class="my-1 border-t border-interface-stroke" />
|
||||
<Button
|
||||
data-testid="clear-history-action"
|
||||
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
|
||||
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="onClearHistoryFromMenu(close)"
|
||||
>
|
||||
<i
|
||||
@@ -76,6 +94,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -90,9 +109,8 @@ const settingStore = useSettingStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
const showClearHistoryAction = computed(() => !isCloud)
|
||||
|
||||
const onClearHistoryFromMenu = (close: () => void) => {
|
||||
@@ -118,4 +136,11 @@ const onToggleDockedJobHistory = async (close: () => void) => {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleRunProgressBar = async () => {
|
||||
await settingStore.set(
|
||||
'Comfy.Queue.ShowRunProgressBar',
|
||||
!isRunProgressBarEnabled.value
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
const popoverCloseSpy = vi.fn()
|
||||
|
||||
vi.mock('@/components/ui/Popover.vue', () => {
|
||||
@@ -24,7 +25,9 @@ vi.mock('@/components/ui/Popover.vue', () => {
|
||||
})
|
||||
|
||||
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||
? true
|
||||
: undefined
|
||||
)
|
||||
const mockSetSetting = vi.fn()
|
||||
const mockSetMany = vi.fn()
|
||||
@@ -52,27 +55,6 @@ const tooltipDirectiveStub = {
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { more: 'More' },
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
clearQueueTooltip: 'Clear queue',
|
||||
clearAllJobsTooltip: 'Cancel all running jobs',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history',
|
||||
dockedJobHistory: 'Docked Job History'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountHeader = (props = {}) =>
|
||||
mount(QueueOverlayHeader, {
|
||||
props: {
|
||||
@@ -88,6 +70,7 @@ const mountHeader = (props = {}) =>
|
||||
|
||||
describe('QueueOverlayHeader', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
popoverCloseSpy.mockClear()
|
||||
mockSetSetting.mockClear()
|
||||
mockSetMany.mockClear()
|
||||
@@ -207,4 +190,19 @@ describe('QueueOverlayHeader', () => {
|
||||
'Comfy.Queue.History.Expanded': true
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles show run progress bar setting from the menu', async () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const showRunProgressBarButton = wrapper.get(
|
||||
'[data-testid="show-run-progress-bar-action"]'
|
||||
)
|
||||
await showRunProgressBarButton.trigger('click')
|
||||
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith(
|
||||
'Comfy.Queue.ShowRunProgressBar',
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
214
src/components/rightSidePanel/errors/MissingNodeCard.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const mockIsCloud = { value: false }
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockApplyChanges = vi.fn()
|
||||
const mockIsRestarting = ref(false)
|
||||
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
|
||||
useApplyChanges: () => ({
|
||||
isRestarting: mockIsRestarting,
|
||||
applyChanges: mockApplyChanges
|
||||
})
|
||||
}))
|
||||
|
||||
const mockIsPackInstalled = vi.fn(() => false)
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShouldShowManagerButtons = { value: false }
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
shouldShowManagerButtons: mockShouldShowManagerButtons
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./MissingPackGroupRow.vue', () => ({
|
||||
default: {
|
||||
name: 'MissingPackGroupRow',
|
||||
template: '<div class="pack-row" />',
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
}))
|
||||
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingNodePacks: {
|
||||
ossMessage: 'Missing node packs detected. Install them.',
|
||||
cloudMessage: 'Unsupported node packs detected.',
|
||||
applyChanges: 'Apply Changes'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
packId: `pack-${i}`,
|
||||
nodeTypes: [
|
||||
{ type: `MissingNode${i}`, nodeId: String(i), isReplaceable: false }
|
||||
],
|
||||
isResolving: false
|
||||
}))
|
||||
}
|
||||
|
||||
function mountCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MissingNodeCard', () => {
|
||||
beforeEach(() => {
|
||||
mockApplyChanges.mockClear()
|
||||
mockIsPackInstalled.mockReset()
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockIsCloud.value = false
|
||||
mockShouldShowManagerButtons.value = false
|
||||
mockIsRestarting.value = false
|
||||
})
|
||||
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders cloud message when isCloud is true', () => {
|
||||
mockIsCloud.value = true
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Unsupported node packs detected')
|
||||
})
|
||||
|
||||
it('renders OSS message when isCloud is false', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Missing node packs detected')
|
||||
})
|
||||
|
||||
it('renders correct number of MissingPackGroupRow components', () => {
|
||||
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders zero rows when missingPackGroups is empty', () => {
|
||||
const wrapper = mountCard({ missingPackGroups: [] })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
const wrapper = mountCard({
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: true
|
||||
})
|
||||
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
|
||||
expect(row.props('showInfoButton')).toBe(true)
|
||||
expect(row.props('showNodeIdBadge')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apply Changes Section', () => {
|
||||
it('hides Apply Changes when manager is not enabled', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('Apply Changes')
|
||||
})
|
||||
|
||||
it('hides Apply Changes when manager enabled but no packs pending', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('Apply Changes')
|
||||
})
|
||||
|
||||
it('shows Apply Changes when at least one pack is pending restart', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Apply Changes')
|
||||
})
|
||||
|
||||
it('displays spinner during restart', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockIsRestarting.value = true
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disables button during restart', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockIsRestarting.value = true
|
||||
const wrapper = mountCard()
|
||||
const btn = wrapper.find('button')
|
||||
expect(btn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls applyChanges when Apply Changes button is clicked', async () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
const wrapper = mountCard()
|
||||
const btn = wrapper.find('button')
|
||||
await btn.trigger('click')
|
||||
expect(mockApplyChanges).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
it('emits locateNode when child emits locate-node', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
|
||||
await row.vm.$emit('locate-node', '42')
|
||||
expect(wrapper.emitted('locateNode')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
|
||||
})
|
||||
|
||||
it('emits openManagerInfo when child emits open-manager-info', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
|
||||
await row.vm.$emit('open-manager-info', 'pack-0')
|
||||
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
|
||||
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
|
||||
})
|
||||
})
|
||||
})
|
||||
368
src/components/rightSidePanel/errors/MissingPackGroupRow.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const mockInstallAllPacks = vi.fn()
|
||||
const mockIsInstalling = ref(false)
|
||||
const mockIsPackInstalled = vi.fn(() => false)
|
||||
const mockShouldShowManagerButtons = { value: false }
|
||||
const mockOpenManager = vi.fn()
|
||||
const mockMissingNodePacks = ref<Array<{ id: string; name: string }>>([])
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
|
||||
() => ({
|
||||
useMissingNodes: () => ({
|
||||
missingNodePacks: mockMissingNodePacks,
|
||||
isLoading: mockIsLoading
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/usePackInstall',
|
||||
() => ({
|
||||
usePackInstall: () => ({
|
||||
isInstalling: mockIsInstalling,
|
||||
installAllPacks: mockInstallAllPacks
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
shouldShowManagerButtons: mockShouldShowManagerButtons,
|
||||
openManager: mockOpenManager
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/types/comfyManagerTypes', () => ({
|
||||
ManagerTab: { Missing: 'missing', All: 'all' }
|
||||
}))
|
||||
|
||||
import MissingPackGroupRow from './MissingPackGroupRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
loading: 'Loading'
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate node on canvas',
|
||||
missingNodePacks: {
|
||||
unknownPack: 'Unknown pack',
|
||||
installNodePack: 'Install node pack',
|
||||
installing: 'Installing...',
|
||||
installed: 'Installed',
|
||||
searchInManager: 'Search in Node Manager',
|
||||
viewInManager: 'View in Manager',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makeGroup(
|
||||
overrides: Partial<MissingPackGroup> = {}
|
||||
): MissingPackGroup {
|
||||
return {
|
||||
packId: 'my-pack',
|
||||
nodeTypes: [
|
||||
{ type: 'MissingA', nodeId: '10', isReplaceable: false },
|
||||
{ type: 'MissingB', nodeId: '11', isReplaceable: false }
|
||||
],
|
||||
isResolving: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingPackGroupRow, {
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MissingPackGroupRow', () => {
|
||||
beforeEach(() => {
|
||||
mockInstallAllPacks.mockClear()
|
||||
mockOpenManager.mockClear()
|
||||
mockIsPackInstalled.mockReset()
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockShouldShowManagerButtons.value = false
|
||||
mockIsInstalling.value = false
|
||||
mockMissingNodePacks.value = []
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders pack name from packId', () => {
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('my-pack')
|
||||
})
|
||||
|
||||
it('renders "Unknown pack" when packId is null', () => {
|
||||
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
|
||||
expect(wrapper.text()).toContain('Unknown pack')
|
||||
})
|
||||
|
||||
it('renders loading text when isResolving is true', () => {
|
||||
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
|
||||
expect(wrapper.text()).toContain('Loading')
|
||||
})
|
||||
|
||||
it('renders node count', () => {
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('renders count of 5 for 5 nodeTypes', () => {
|
||||
const wrapper = mountRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
|
||||
type: `Node${i}`,
|
||||
nodeId: String(i),
|
||||
isReplaceable: false
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('(5)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).not.toContain('MissingA')
|
||||
})
|
||||
|
||||
it('expands when chevron is clicked', async () => {
|
||||
const wrapper = mountRow()
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('MissingA')
|
||||
expect(wrapper.text()).toContain('MissingB')
|
||||
})
|
||||
|
||||
it('collapses when chevron is clicked again', async () => {
|
||||
const wrapper = mountRow()
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('MissingA')
|
||||
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('MissingA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
async function expand(wrapper: ReturnType<typeof mountRow>) {
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
}
|
||||
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
const wrapper = mountRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).toContain('NodeA')
|
||||
expect(wrapper.text()).toContain('NodeB')
|
||||
expect(wrapper.text()).toContain('NodeC')
|
||||
})
|
||||
|
||||
it('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const wrapper = mountRow({ showNodeIdBadge: true })
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).toContain('#10')
|
||||
})
|
||||
|
||||
it('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const wrapper = mountRow({ showNodeIdBadge: false })
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).not.toContain('#10')
|
||||
})
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const wrapper = mountRow({ showNodeIdBadge: true })
|
||||
await expand(wrapper)
|
||||
await wrapper
|
||||
.get('button[aria-label="Locate node on canvas"]')
|
||||
.trigger('click')
|
||||
expect(wrapper.emitted('locateNode')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const wrapper = mountRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
expect(
|
||||
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('handles mixed nodeTypes with and without nodeId', async () => {
|
||||
const wrapper = mountRow({
|
||||
showNodeIdBadge: true,
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'WithId', nodeId: '100', isReplaceable: false },
|
||||
{ type: 'WithoutId', isReplaceable: false } as never
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).toContain('WithId')
|
||||
expect(wrapper.text()).toContain('WithoutId')
|
||||
expect(
|
||||
wrapper.findAll('button[aria-label="Locate node on canvas"]')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Manager Integration', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).not.toContain('Install node pack')
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
|
||||
expect(wrapper.text()).not.toContain('Install node pack')
|
||||
})
|
||||
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('Search in Node Manager')
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('Installed')
|
||||
})
|
||||
|
||||
it('shows spinner when installing', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsInstalling.value = true
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows install button when not installed and pack found', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('Install node pack')
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
await wrapper.get('button:not([aria-label])').trigger('click')
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows loading spinner when registry is loading', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Info Button', () => {
|
||||
it('shows Info button when showInfoButton true and packId not null', () => {
|
||||
const wrapper = mountRow({ showInfoButton: true })
|
||||
expect(
|
||||
wrapper.find('button[aria-label="View in Manager"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides Info button when showInfoButton is false', () => {
|
||||
const wrapper = mountRow({ showInfoButton: false })
|
||||
expect(
|
||||
wrapper.find('button[aria-label="View in Manager"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides Info button when packId is null', () => {
|
||||
const wrapper = mountRow({
|
||||
showInfoButton: true,
|
||||
group: makeGroup({ packId: null })
|
||||
})
|
||||
expect(
|
||||
wrapper.find('button[aria-label="View in Manager"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('emits openManagerInfo when Info button is clicked', async () => {
|
||||
const wrapper = mountRow({ showInfoButton: true })
|
||||
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
|
||||
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
|
||||
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty nodeTypes array', () => {
|
||||
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(wrapper.text()).toContain('(0)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -109,6 +109,7 @@
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
@@ -179,14 +180,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from './SwapNodesCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -199,8 +200,7 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -264,12 +264,12 @@ function handleOpenManagerInfo(packId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
replaceGroup(group)
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
|
||||
const replaced = replaceNodesInPlace(allNodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType(replaced)
|
||||
}
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
|
||||
187
src/components/rightSidePanel/errors/swapNodeGroups.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
inferPackFromNodeName: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeTitleUtil', () => ({
|
||||
resolveNodeDisplayName: vi.fn(() => '')
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
type: string,
|
||||
opts: {
|
||||
nodeId?: string
|
||||
isReplaceable?: boolean
|
||||
replacement?: { new_node_id: string }
|
||||
} = {}
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
isReplaceable: opts.isReplaceable ?? false,
|
||||
replacement: opts.replacement
|
||||
? {
|
||||
old_node_id: type,
|
||||
new_node_id: opts.replacement.new_node_id,
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe('swapNodeGroups computed', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes(nodeTypes)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const t = (key: string) => key
|
||||
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
|
||||
return swapNodeGroups
|
||||
}
|
||||
|
||||
it('returns empty array when no missing nodes', () => {
|
||||
const swap = getSwapNodeGroups([])
|
||||
expect(swap.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no nodes are replaceable', () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
makeMissingNodeType('NodeA', { isReplaceable: false }),
|
||||
makeMissingNodeType('NodeB', { isReplaceable: false })
|
||||
])
|
||||
expect(swap.value).toEqual([])
|
||||
})
|
||||
|
||||
it('groups replaceable nodes by type', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
makeMissingNodeType('OldNode', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
}),
|
||||
makeMissingNodeType('OldNode', {
|
||||
nodeId: '2',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
expect(swap.value).toHaveLength(1)
|
||||
expect(swap.value[0].type).toBe('OldNode')
|
||||
expect(swap.value[0].newNodeId).toBe('NewNode')
|
||||
expect(swap.value[0].nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates separate groups for different types', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
makeMissingNodeType('TypeA', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewA' }
|
||||
}),
|
||||
makeMissingNodeType('TypeB', {
|
||||
nodeId: '2',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewB' }
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
expect(swap.value).toHaveLength(2)
|
||||
expect(swap.value.map((g) => g.type)).toEqual(['TypeA', 'TypeB'])
|
||||
})
|
||||
|
||||
it('sorts groups alphabetically by type', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
makeMissingNodeType('Zebra', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewZ' }
|
||||
}),
|
||||
makeMissingNodeType('Alpha', {
|
||||
nodeId: '2',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewA' }
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
expect(swap.value[0].type).toBe('Alpha')
|
||||
expect(swap.value[1].type).toBe('Zebra')
|
||||
})
|
||||
|
||||
it('excludes string nodeType entries', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
'StringGroupNode' as unknown as MissingNodeType,
|
||||
makeMissingNodeType('OldNode', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
expect(swap.value).toHaveLength(1)
|
||||
expect(swap.value[0].type).toBe('OldNode')
|
||||
})
|
||||
|
||||
it('sets newNodeId to undefined when replacement is missing', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
makeMissingNodeType('OldNode', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true
|
||||
// no replacement
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
expect(swap.value).toHaveLength(1)
|
||||
expect(swap.value[0].newNodeId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
523
src/components/rightSidePanel/errors/useErrorGroups.test.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
inferPackFromNodeName: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeTitleUtil', () => ({
|
||||
resolveNodeDisplayName: vi.fn(() => '')
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
type: string,
|
||||
opts: {
|
||||
nodeId?: string
|
||||
isReplaceable?: boolean
|
||||
cnrId?: string
|
||||
replacement?: { new_node_id: string }
|
||||
} = {}
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
isReplaceable: opts.isReplaceable ?? false,
|
||||
cnrId: opts.cnrId,
|
||||
replacement: opts.replacement
|
||||
? {
|
||||
old_node_id: type,
|
||||
new_node_id: opts.replacement.new_node_id,
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function createErrorGroups() {
|
||||
const store = useExecutionErrorStore()
|
||||
const searchQuery = ref('')
|
||||
const t = (key: string) => key
|
||||
const groups = useErrorGroups(searchQuery, t)
|
||||
return { store, searchQuery, groups }
|
||||
}
|
||||
|
||||
describe('useErrorGroups', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('missingPackGroups', () => {
|
||||
it('returns empty array when no missing nodes', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingPackGroups.value).toEqual([])
|
||||
})
|
||||
|
||||
it('groups non-replaceable nodes by cnrId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingPackGroups.value).toHaveLength(2)
|
||||
const pack1 = groups.missingPackGroups.value.find(
|
||||
(g) => g.packId === 'pack-1'
|
||||
)
|
||||
expect(pack1?.nodeTypes).toHaveLength(2)
|
||||
const pack2 = groups.missingPackGroups.value.find(
|
||||
(g) => g.packId === 'pack-2'
|
||||
)
|
||||
expect(pack2?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes replaceable nodes from missingPackGroups', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
}),
|
||||
makeMissingNodeType('MissingNode', {
|
||||
nodeId: '2',
|
||||
cnrId: 'some-pack'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingPackGroups.value).toHaveLength(1)
|
||||
expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
|
||||
})
|
||||
|
||||
it('groups nodes without cnrId under null packId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
|
||||
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingPackGroups.value).toHaveLength(1)
|
||||
expect(groups.missingPackGroups.value[0].packId).toBeNull()
|
||||
expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('sorts groups alphabetically with null packId last', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
|
||||
makeMissingNodeType('NodeB', { nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const packIds = groups.missingPackGroups.value.map((g) => g.packId)
|
||||
expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
|
||||
})
|
||||
|
||||
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const group = groups.missingPackGroups.value[0]
|
||||
const types = group.nodeTypes.map((n) =>
|
||||
typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
|
||||
)
|
||||
expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
|
||||
})
|
||||
|
||||
it('handles string nodeType entries', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
'StringGroupNode' as unknown as MissingNodeType
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingPackGroups.value).toHaveLength(1)
|
||||
expect(groups.missingPackGroups.value[0].packId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('allErrorGroups', () => {
|
||||
it('returns empty array when no errors', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.allErrorGroups.value).toEqual([])
|
||||
})
|
||||
|
||||
it('includes missing_node group when missing nodes exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('includes swap_nodes group when replaceable nodes exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const swapGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'swap_nodes'
|
||||
)
|
||||
expect(swapGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('includes both swap_nodes and missing_node when both exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
}),
|
||||
makeMissingNodeType('MissingNode', {
|
||||
nodeId: '2',
|
||||
cnrId: 'some-pack'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const types = groups.allErrorGroups.value.map((g) => g.type)
|
||||
expect(types).toContain('swap_nodes')
|
||||
expect(types).toContain('missing_node')
|
||||
})
|
||||
|
||||
it('swap_nodes has lower priority than missing_node', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
}),
|
||||
makeMissingNodeType('MissingNode', {
|
||||
nodeId: '2',
|
||||
cnrId: 'some-pack'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const swapIdx = groups.allErrorGroups.value.findIndex(
|
||||
(g) => g.type === 'swap_nodes'
|
||||
)
|
||||
const missingIdx = groups.allErrorGroups.value.findIndex(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(swapIdx).toBeLessThan(missingIdx)
|
||||
})
|
||||
|
||||
it('includes execution error groups from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'value_not_valid',
|
||||
message: 'Value not valid',
|
||||
details: 'some detail'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroups = groups.allErrorGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('includes execution error from runtime errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastExecutionError = {
|
||||
prompt_id: 'test-prompt',
|
||||
timestamp: Date.now(),
|
||||
node_id: 5,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'CUDA out of memory',
|
||||
traceback: ['line 1', 'line 2'],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroups = groups.allErrorGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastPromptError = {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'No outputs',
|
||||
details: ''
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.title === 'No outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('sorts cards within an execution group by nodeId numerically', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'10': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'2': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
|
||||
expect(nodeIds).toEqual(['1', '1:20', '2'])
|
||||
})
|
||||
|
||||
it('sorts deeply nested nodeIds by each segment numerically', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'10:11:99': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'10:11:12': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'10:2': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
|
||||
expect(nodeIds).toEqual(['10:2', '10:11:12', '10:11:99'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filteredGroups', () => {
|
||||
it('returns all groups when search query is empty', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('filters groups based on search query', async () => {
|
||||
const { store, groups, searchQuery } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'value_error',
|
||||
message: 'Value error in sampler',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'file_not_found',
|
||||
message: 'File not found',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
searchQuery.value = 'sampler'
|
||||
await nextTick()
|
||||
|
||||
const executionGroups = groups.filteredGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
for (const group of executionGroups) {
|
||||
if (group.type !== 'execution') continue
|
||||
const hasMatch = group.cards.some(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes('sampler') ||
|
||||
c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
|
||||
)
|
||||
expect(hasMatch).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupedErrorMessages', () => {
|
||||
it('returns empty array when no errors', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique error messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'err_a', message: 'Error A', details: '' },
|
||||
{ type: 'err_b', message: 'Error B', details: '' }
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toContain('Error A')
|
||||
expect(messages).toContain('Error B')
|
||||
// Deduplication: Error A appears twice but should only be listed once
|
||||
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes missing node group title as message', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapseState', () => {
|
||||
it('returns a reactive object', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.collapseState).toBeDefined()
|
||||
expect(typeof groups.collapseState).toBe('object')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,10 @@ import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
@@ -151,12 +154,16 @@ function addCardErrorToGroup(
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([title, groupData]) => ({
|
||||
type: 'execution' as const,
|
||||
title,
|
||||
cards: Array.from(groupData.cards.values()),
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
enableEmptyState,
|
||||
tooltip,
|
||||
size = 'default',
|
||||
tooltipDelay = 1000,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
@@ -18,6 +19,7 @@ const {
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
size?: 'default' | 'lg'
|
||||
tooltipDelay?: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
@@ -27,7 +29,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (!tooltip) return undefined
|
||||
return { value: tooltip, showDelay: 1000 }
|
||||
return { value: tooltip, showDelay: tooltipDelay }
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
essentials_category: 'basic'
|
||||
essentials_category: 'basic',
|
||||
python_module: 'comfy_essentials'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
@@ -58,9 +59,13 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
expect(wrapper.text()).toContain('Most relevant')
|
||||
expect(wrapper.text()).toContain('Recents')
|
||||
expect(wrapper.text()).toContain('Favorites')
|
||||
expect(wrapper.text()).toContain('Essentials')
|
||||
expect(wrapper.text()).toContain('Custom')
|
||||
expect(wrapper.text()).toContain('Blueprints')
|
||||
expect(wrapper.text()).toContain('Partner')
|
||||
expect(wrapper.text()).toContain('Comfy')
|
||||
expect(wrapper.text()).toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should mark the selected preset category as selected', async () => {
|
||||
|
||||