Compare commits
24 Commits
fix/textar
...
AddTemplat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf63a5cc71 | ||
|
|
8361122586 | ||
|
|
55dea32e00 | ||
|
|
07d49cbe64 | ||
|
|
fdd963a630 | ||
|
|
b638e6a577 | ||
|
|
c7409b6830 | ||
|
|
ff972fbefb | ||
|
|
b18fd8e57a | ||
|
|
c25f9a0e93 | ||
|
|
d7546e68ef | ||
|
|
2634acdd8c | ||
|
|
b3a5317500 | ||
|
|
19dc48b69a | ||
|
|
f811b173c6 | ||
|
|
b29dbec916 | ||
|
|
0f4bceafdd | ||
|
|
d601aba721 | ||
|
|
ddcfdb924d | ||
|
|
d6c902187d | ||
|
|
dd8520020e | ||
|
|
a0e8f7d960 | ||
|
|
02e926471f | ||
|
|
8998d92e1b |
12
.github/workflows/ci-lint-format.yaml
vendored
@@ -26,6 +26,14 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Run ESLint with auto-fix
|
||||
run: pnpm lint:fix
|
||||
|
||||
@@ -60,6 +68,10 @@ jobs:
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
run: pnpm typecheck:browser
|
||||
|
||||
- name: Comment on PR about auto-fix
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
|
||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -4,8 +4,10 @@ name: 'CI: Tests E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
2
.github/workflows/ci-tests-unit.yaml
vendored
@@ -4,8 +4,10 @@ name: 'CI: Tests Unit'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -37,6 +37,10 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g., `pnpm test:unit`, `pnpm lint`). To run arbitrary packages not in scripts, use `pnpx` or `pnpm dlx` — never `npx`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
|
||||
@@ -77,7 +77,7 @@ const createMockElectronAPI = () => {
|
||||
}
|
||||
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||
const globalWindow = window as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"id": "43e9499c-2512-43b5-a5a1-2485eb65da32",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [170.55728894250745, 515.6401487466619],
|
||||
"size": [282.8166809082031, 363.8333435058594],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [7, 9]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
|
||||
"pos": [500.2639113468392, 519.9960755960157],
|
||||
"size": [464.95001220703125, 615.8333129882812],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["2", "$$canvas-image-preview"],
|
||||
["4", "$$canvas-image-preview"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "a7a0350a-af99-4d26-9391-450b4f726206",
|
||||
"pos": [1000.5293620197185, 499.9253405678786],
|
||||
"size": [225, 359.8333435058594],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["6", "$$canvas-image-preview"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[7, 1, 0, 7, 0, "IMAGE"],
|
||||
[9, 1, 0, 8, 0, "IMAGE"],
|
||||
[10, 7, 0, 8, 1, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 8,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [297.7833638107301, 502.6302057820892, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1052.8175480718996, 502.6302057820892, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "afc8dbc3-12e6-4b3c-9840-9b398d06e6bd",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1, 2],
|
||||
"localized_name": "images",
|
||||
"pos": [397.7833638107301, 522.6302057820892]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "d0a84974-5f4d-4f4b-b23a-2e7288a9689d",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [5],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1072.8175480718996, 522.6302057820892]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewImage",
|
||||
"pos": [767.8225773415076, 602.8695134060456],
|
||||
"size": [225, 303.8333435058594],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewImage",
|
||||
"pos": [754.9358989867657, 188.55375831225257],
|
||||
"size": [225, 303.8333435058594],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImageInvert",
|
||||
"pos": [477.783932416778, 542.2440719627998],
|
||||
"size": [225, 71.83333587646484],
|
||||
"flags": {
|
||||
"collapsed": false
|
||||
},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [3, 5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 4,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 2,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "a7a0350a-af99-4d26-9391-450b4f726206",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 8,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [973.7423316105073, 561.9744246746379, 120, 80]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1905.487372786412, 581.9744246746379, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "20ac4159-6814-4d40-a217-ea260152b689",
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [4],
|
||||
"localized_name": "image1",
|
||||
"pos": [1073.7423316105073, 581.9744246746379]
|
||||
},
|
||||
{
|
||||
"id": "c3759a8c-914e-4450-bc41-ca683ffce96b",
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [8],
|
||||
"localized_name": "image2",
|
||||
"shape": 7,
|
||||
"pos": [1073.7423316105073, 601.9744246746379]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "ImageStitch",
|
||||
"pos": [1153.7423085222254, 396.2033931749105],
|
||||
"size": [270, 225.1666717529297],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image1",
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "image2",
|
||||
"name": "image2",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [6]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageStitch"
|
||||
},
|
||||
"widgets_values": ["right", true, 0, "white"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1620.4874189629757, 529.9122050216333],
|
||||
"size": [225, 307.8333435058594],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 5,
|
||||
"target_slot": 1,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7269777827561446,
|
||||
"offset": [-35.273237658266034, -55.17394203309256]
|
||||
},
|
||||
"frontendVersion": "1.40.8"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
170
browser_tests/assets/subgraphs/subgraph-with-preview-node.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"id": "preview-subgraph-test-001",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"pos": [318.6320139868054, 212.9091015141833],
|
||||
"size": [225, 368],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["10", "filename_prefix"],
|
||||
["10", "$$canvas-image-preview"]
|
||||
],
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.13.0",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.6.2",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [-0.5080003681592018, 211.3051121416672],
|
||||
"size": [282.8333435058594, 364],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {}
|
||||
},
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.13.0",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [[2, 11, 0, 5, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [300, 350, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 350, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "img-slot-001",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": [400, 370]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [500.0046924937855, 300.0146992076527],
|
||||
"size": [315, 340],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.13.0",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.6.2",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.1819400303977265,
|
||||
"offset": [81.66005130613983, -19.028558221588725]
|
||||
},
|
||||
"frontendVersion": "1.40.3",
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -11,7 +11,10 @@ export class CommandHelper {
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ commandId, metadata }) => {
|
||||
return window['app'].extensionManager.command.execute(commandId, {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
|
||||
return app.extensionManager.command.execute(commandId, {
|
||||
metadata
|
||||
})
|
||||
},
|
||||
|
||||
@@ -115,6 +115,16 @@ export class DragDropHelper {
|
||||
const dragOverEvent = new DragEvent('dragover', eventOptions)
|
||||
const dropEvent = new DragEvent('drop', eventOptions)
|
||||
|
||||
const graphCanvasElement = document.querySelector('#graph-canvas')
|
||||
|
||||
// Keep Litegraph's drag-over node tracking in sync when the drop target is a
|
||||
// Vue node DOM overlay outside of the graph canvas element.
|
||||
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
|
||||
graphCanvasElement.dispatchEvent(
|
||||
new DragEvent('dragover', eventOptions)
|
||||
)
|
||||
}
|
||||
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
|
||||
@@ -33,6 +33,10 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => window.app!.graph.nodes.length)
|
||||
}
|
||||
|
||||
async getNodes(): Promise<LGraphNode[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
return window.app!.graph.nodes
|
||||
|
||||
@@ -36,7 +36,7 @@ export class SubgraphHelper {
|
||||
const currentGraph = app.canvas!.graph!
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
if (!('inputNode' in currentGraph)) {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
@@ -88,7 +88,7 @@ export class SubgraphHelper {
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event as unknown as CanvasPointerEvent,
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
@@ -121,7 +121,7 @@ export class SubgraphHelper {
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event as unknown as CanvasPointerEvent,
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
@@ -129,7 +129,7 @@ export class SubgraphHelper {
|
||||
// Trigger double-click
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(
|
||||
event as unknown as CanvasPointerEvent
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const TestIds = {
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
missingNodes: 'missing-nodes-warning',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
},
|
||||
@@ -46,8 +47,12 @@ export const TestIds = {
|
||||
widgets: {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
@@ -70,6 +75,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
(id: string) => string
|
||||
|
||||
@@ -128,7 +128,8 @@ class NodeSlotReference {
|
||||
nodeSize: [node.size[0], node.size[1]],
|
||||
rawConnectionPos: [rawPos[0], rawPos[1]],
|
||||
convertedPos: [convertedPos[0], convertedPos[1]],
|
||||
currentGraphType: window.app!.canvas.graph!.constructor.name
|
||||
currentGraphType:
|
||||
'inputNode' in window.app!.canvas.graph! ? 'Subgraph' : 'LGraph'
|
||||
}
|
||||
)
|
||||
|
||||
@@ -461,18 +462,44 @@ export class NodeReference {
|
||||
// Try multiple positions to avoid DOM widget interference
|
||||
const clickPositions = [
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
|
||||
{
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y + nodeSize.height / 2
|
||||
},
|
||||
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
|
||||
]
|
||||
|
||||
// Click the enter_subgraph title button (top-right of title bar).
|
||||
// This is more reliable than dblclick on the node body because
|
||||
// promoted DOM widgets can overlay the body and intercept events.
|
||||
const subgraphButtonPos = {
|
||||
x: nodePos.x + nodeSize.width - 15,
|
||||
y: nodePos.y - titleHeight / 2
|
||||
}
|
||||
|
||||
const checkIsInSubgraph = async () => {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
await expect(async () => {
|
||||
// Try just clicking the enter button first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
position: subgraphButtonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
interface TestWindow extends Window {
|
||||
__ws__?: Record<string, WebSocket>
|
||||
}
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
||||
}>({
|
||||
|
||||
64
browser_tests/helpers/promotedWidgets.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizePromotedWidgets(
|
||||
value: unknown
|
||||
): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return node?.properties?.proxyWidgets ?? []
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<string[]> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.map(([, widgetName]) => widgetName)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCount(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<number> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,9 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.filename
|
||||
})
|
||||
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
})
|
||||
|
||||
// Failing on CI
|
||||
@@ -51,7 +53,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
})
|
||||
|
||||
test('Can display default title', async ({ comfyPage }) => {
|
||||
expect(await comfyPage.page.title()).toBe('ComfyUI')
|
||||
await expect.poll(() => comfyPage.page.title()).toBe('ComfyUI')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,12 +160,12 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
})
|
||||
// Click empty space to trigger a change detection.
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
})
|
||||
|
||||
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
|
||||
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,11 +245,18 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.serialize()
|
||||
const graph = window.app!.graph!
|
||||
if (typeof graph.serialize !== 'function') {
|
||||
throw new Error('app.graph.serialize is not available')
|
||||
}
|
||||
return graph.serialize() as {
|
||||
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||
}
|
||||
})
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
const nodes = parsed.nodes
|
||||
for (const node of nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import type { Keybinding } from '../../src/platform/keybindings/types'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -15,8 +16,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -25,8 +27,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
|
||||
// Verify the missing node text includes subgraph context
|
||||
@@ -38,13 +41,14 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
const missingNodesWarning = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodes
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.page
|
||||
.locator('.p-dialog')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
|
||||
// Wait for any async operations to complete after dialog closes
|
||||
await comfyPage.nextFrame()
|
||||
@@ -55,9 +59,14 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
|
||||
// Undo and redo the change
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
await expect(async () => {
|
||||
await expect(missingNodesWarning).not.toBeVisible()
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
@@ -401,7 +410,7 @@ test.describe('Signin dialog', () => {
|
||||
test('Paste content to signin dialog should not paste node on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
|
||||
const nodeNum = await comfyPage.nodeOps.getNodeCount()
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyLatentWidgetClick
|
||||
})
|
||||
@@ -424,6 +433,6 @@ test.describe('Signin dialog', () => {
|
||||
await input.press('Control+v')
|
||||
await expect(input).toHaveValue('test_password')
|
||||
|
||||
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
|
||||
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 91 KiB |
@@ -203,19 +203,19 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await topbar.switchTheme('light')
|
||||
|
||||
// Verify menu stays open and Light theme shows as active
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
|
||||
// Check that Light theme is active
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Screenshot with light theme active
|
||||
await comfyPage.attachScreenshot('theme-menu-light-active')
|
||||
|
||||
// Verify ColorPalette setting is set to "light"
|
||||
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
|
||||
'light'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
|
||||
.toBe('light')
|
||||
|
||||
// Close menu to see theme change
|
||||
await topbar.closeTopbarMenu()
|
||||
@@ -228,20 +228,22 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await topbar.switchTheme('dark')
|
||||
|
||||
// Verify menu stays open and Dark theme shows as active
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeItems2.submenu).toBeVisible()
|
||||
|
||||
// Check that Dark theme is active and Light theme is not
|
||||
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
|
||||
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeItems2.submenu).toBeVisible()
|
||||
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
|
||||
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(
|
||||
false
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Screenshot with dark theme active
|
||||
await comfyPage.attachScreenshot('theme-menu-dark-active')
|
||||
|
||||
// Verify ColorPalette setting is set to "dark"
|
||||
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
|
||||
'dark'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
|
||||
.toBe('dark')
|
||||
|
||||
// Close menu
|
||||
await topbar.closeTopbarMenu()
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
@@ -83,17 +83,15 @@ test.describe('Workflows sidebar', () => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
|
||||
const originalNodeCount = (await comfyPage.nodeOps.getNodes()).length
|
||||
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toEqual(originalNodeCount + 1)
|
||||
|
||||
await tab.getPersistedItem('workflow1.json').click()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
|
||||
.toEqual(1)
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
|
||||
})
|
||||
|
||||
test('Can rename nested workflow from opened workflow item', async ({
|
||||
|
||||
@@ -53,7 +53,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
): Promise<boolean> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
622
browser_tests/tests/subgraphPromotion.spec.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Check whether we're currently in a subgraph.
|
||||
*/
|
||||
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
await expect(parentLink).toBeVisible()
|
||||
await parentLink.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.describe('Auto-promotion on Convert to Subgraph', () => {
|
||||
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select just the KSampler node (id 3) which has a "seed" widget
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SubgraphNode should exist
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
// The KSampler has a "seed" widget which is in the recommended list.
|
||||
// The promotion store should have at least the seed widget promoted.
|
||||
const nodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// SubgraphNode should have widgets (promoted views)
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('CLIPTextEncode text widget is auto-promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select the positive CLIPTextEncode node (id 6)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promotedNames.length).toBeGreaterThan(0)
|
||||
|
||||
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||
// should be promoted
|
||||
expect(promotedNames).toContain('text')
|
||||
})
|
||||
|
||||
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select the SaveImage node (id 9 in default workflow)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
|
||||
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||
expect(promotedNames).toContain('filename_prefix')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The subgraph node (id 11) should have a text widget promoted
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets all render on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
const count = await textareas.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// SubgraphNode (id 11) should render with its body
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// It should have the Enter Subgraph button
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
// The promoted text widget should render inside the node
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
// Widgets section should exist and have at least one widget
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
const count = await widgets.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Value changes on promoted widget sync to interior widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
// Type into the promoted textarea on the SubgraphNode
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Interior CLIPTextEncode textarea should have the same value
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
})
|
||||
|
||||
test('Value changes on interior widget sync to promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'interior-value-sync-test'
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Type into the interior CLIPTextEncode textarea
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await interiorTextarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(testContent)
|
||||
})
|
||||
|
||||
test('Value persists through repeated navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'persistence-through-navigation'
|
||||
|
||||
// Set value on promoted widget
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
|
||||
// Navigate in and out multiple times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(testContent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Can promote a widget from inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get the KSampler node (id 1) inside the subgraph
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
|
||||
// Right-click on the KSampler's "steps" widget (index 2) to promote it
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Can un-promote a widget from inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// First promote a canvas-rendered widget (KSampler "steps")
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
expect(initialWidgetCount).toBeGreaterThan(0)
|
||||
|
||||
// Navigate back in and un-promote
|
||||
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode2.navigateIntoSubgraph()
|
||||
const stepsWidget2 = await (
|
||||
await comfyPage.nodeOps.getNodeRefById('1')
|
||||
).getWidget(2)
|
||||
const widgetPos2 = await stepsWidget2.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos2,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const unpromoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Un-Promote Widget/ })
|
||||
|
||||
await expect(unpromoteEntry).toBeVisible()
|
||||
await unpromoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pseudo-Widget Promotion', () => {
|
||||
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames.length).toBeGreaterThan(0)
|
||||
expect(promotedNames).toContain('filename_prefix')
|
||||
})
|
||||
|
||||
test('Converting SaveImage to subgraph promotes its widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select SaveImage (id 9)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||
const nodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promotedNames.length).toBeGreaterThan(0)
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
}, serialized as ComfyWorkflowJSON)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vue Mode - Promoted Preview Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// The node body should exist
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promotions exist
|
||||
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(namesBefore.length).toBeGreaterThan(0)
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('11')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
})
|
||||
|
||||
test('Removing I/O slot removes associated promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(initialWidgetCount).toBeGreaterThan(0)
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.rightClickInputSlot('text')
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Widget count should be reduced
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 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: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
@@ -2,15 +2,20 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '../../../../helpers/promotedWidgets'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
@@ -25,16 +30,19 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return imagePreview
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const imagePreview = await loadImageOnNode(comfyPage)
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.locator('[role="img"]').hover()
|
||||
await imagePreview.locator('[role="img"]').focus()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
@@ -42,9 +50,11 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
@@ -55,4 +65,69 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(contextMenu.getByText('Save Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'renders promoted image previews for each subgraph node',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const firstSubgraphNode = comfyPage.vueNodes.getNodeLocator('7')
|
||||
const secondSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
|
||||
await expect(firstSubgraphNode).toBeVisible()
|
||||
await expect(secondSubgraphNode).toBeVisible()
|
||||
|
||||
const firstPromotedWidgets = await getPromotedWidgetNames(comfyPage, '7')
|
||||
const secondPromotedWidgets = await getPromotedWidgetNames(comfyPage, '8')
|
||||
expect(firstPromotedWidgets).toEqual([
|
||||
'$$canvas-image-preview',
|
||||
'$$canvas-image-preview'
|
||||
])
|
||||
expect(secondPromotedWidgets).toEqual(['$$canvas-image-preview'])
|
||||
|
||||
expect(
|
||||
await getPromotedWidgetCountByName(
|
||||
comfyPage,
|
||||
'7',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
).toBe(2)
|
||||
expect(
|
||||
await getPromotedWidgetCountByName(
|
||||
comfyPage,
|
||||
'8',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
).toBe(1)
|
||||
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const firstPreviewImages = firstSubgraphNode.locator('.image-preview img')
|
||||
const secondPreviewImages =
|
||||
secondSubgraphNode.locator('.image-preview img')
|
||||
|
||||
await expect(firstPreviewImages).toHaveCount(2, { timeout: 30_000 })
|
||||
await expect(secondPreviewImages).toHaveCount(1, { timeout: 30_000 })
|
||||
|
||||
await expect(firstPreviewImages.first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(firstPreviewImages.nth(1)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(secondPreviewImages.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-multiple-promoted-previews.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
@@ -9,17 +9,20 @@ test.describe('Vue Upload Widgets', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'should hide canvas-only upload buttons',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-upload-widgets.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
comfyPage.page.getByText('choose file to upload', { exact: true })
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
@@ -24,6 +24,28 @@ These correspond to the two data formats in `ComfyMetadata` embedded in output f
|
||||
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
|
||||
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
|
||||
|
||||
## PrimitiveNode and copy/paste
|
||||
|
||||
`PrimitiveNode` creates widgets dynamically on connection — it starts as an empty polymorphic node and morphs to match its target widget in `_onFirstConnection()`. This interacts badly with the copy/paste pipeline.
|
||||
|
||||
### The clone→serialize gap
|
||||
|
||||
`LGraphCanvas._serializeItems()` copies nodes via `item.clone()?.serialize()` (line 3911). For PrimitiveNode this fails:
|
||||
|
||||
1. `clone()` calls `this.serialize()` on the **original** node (which has widgets, so `widgets_values` is captured correctly).
|
||||
2. `clone()` creates a **fresh** PrimitiveNode via `LiteGraph.createNode()` and calls `configure(data)` on it — this stores `widgets_values` on the instance.
|
||||
3. But the fresh PrimitiveNode has no `this.widgets` (widgets are created only on connection), so when `serialize()` is called on the clone, `LGraphNode.serialize()` skips the `widgets_values` block entirely (line 964: `if (widgets && this.serialize_widgets)`).
|
||||
|
||||
Result: `widgets_values` is silently dropped from the clipboard data.
|
||||
|
||||
### Why seed survives but control_after_generate doesn't
|
||||
|
||||
When the pasted PrimitiveNode reconnects to the pasted target node, `_createWidget()` copies `theirWidget.value` from the target (line 254). This restores the **primary** widget value (e.g., `seed`).
|
||||
|
||||
But `control_after_generate` is a **secondary** widget created by `addValueControlWidgets()`, which reads its initial value from `this.widgets_values?.[1]` (line 263). That value was lost during clone→serialize, so it falls back to `'fixed'` (line 265).
|
||||
|
||||
See [ADR-0006](adr/0006-primitive-node-copy-paste-lifecycle.md) for proposed fixes and design tradeoffs.
|
||||
|
||||
## Code references
|
||||
|
||||
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
|
||||
@@ -31,3 +53,6 @@ These correspond to the two data formats in `ComfyMetadata` embedded in output f
|
||||
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
|
||||
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
|
||||
- Metadata types: `src/types/metadataTypes.ts`
|
||||
- PrimitiveNode: `src/extensions/core/widgetInputs.ts`
|
||||
- Copy/paste serialization: `src/lib/litegraph/src/LGraphCanvas.ts` `_serializeItems()`
|
||||
- Clone: `src/lib/litegraph/src/LGraphNode.ts` `clone()`
|
||||
|
||||
45
docs/adr/0006-primitive-node-copy-paste-lifecycle.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 6. PrimitiveNode Copy/Paste Lifecycle
|
||||
|
||||
Date: 2026-02-22
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
PrimitiveNode creates widgets dynamically on connection. When copied, the clone has no `this.widgets`, so `LGraphNode.serialize()` drops `widgets_values` from the clipboard data. This causes secondary widget values (e.g., `control_after_generate`) to be lost on paste. See [WIDGET_SERIALIZATION.md](../WIDGET_SERIALIZATION.md#primitiveno-and-copypaste) for the full mechanism.
|
||||
|
||||
Related: [#1757](https://github.com/Comfy-Org/ComfyUI_frontend/issues/1757), [#8938](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8938)
|
||||
|
||||
## Options
|
||||
|
||||
### A. Minimal fix: override `serialize()` on PrimitiveNode
|
||||
|
||||
Override `serialize()` to fall back to `this.widgets_values` (set during `configure()`) when the base implementation omits it due to missing `this.widgets`.
|
||||
|
||||
- **Pro**: No change to connection lifecycle semantics. Lowest risk.
|
||||
- **Pro**: Doesn't affect workflow save/load (which already works via `onAfterGraphConfigured`).
|
||||
- **Con**: Doesn't address the deeper design issue — primitives are still empty on copy.
|
||||
|
||||
### B. Clone-configured-instance lifecycle
|
||||
|
||||
On copy, the primitive is a clone of the configured instance (with widgets intact). On disconnect or paste without connections, it returns to empty state.
|
||||
|
||||
- **Pro**: Copy→serialize captures `widgets_values` correctly. Matches OOP expectations.
|
||||
- **Pro**: Secondary widget state survives round-trips without special-casing.
|
||||
- **Con**: `input.widget[CONFIG]` allows extensions to make PrimitiveNode create a _different_ widget than the target. Widget config is derived at connection time, not stored, so cloning the configured state may not be faithful.
|
||||
- **Con**: Deserialization ordering — `configure()` runs before links are restored. PrimitiveNode needs links to know what widgets to create. `onAfterGraphConfigured()` handles this for workflow load, but copy/paste uses a different code path.
|
||||
- **Con**: Higher risk of regressions in extension compatibility.
|
||||
|
||||
### C. Projection model (like Subgraph widgets)
|
||||
|
||||
Primitives act as a synchronization mechanism — no own state, just a projection of the target widget's resolved value.
|
||||
|
||||
- **Pro**: Cleanest conceptual model. Eliminates state duplication.
|
||||
- **Con**: Primitives can connect to multiple targets. Projection with multiple targets is ambiguous.
|
||||
- **Con**: Major architectural change with broad impact.
|
||||
|
||||
## Decision
|
||||
|
||||
Pending. Option A is the most pragmatic first step. Option B can be revisited after Option A ships and stabilizes.
|
||||
@@ -57,7 +57,7 @@ settings: testData as any
|
||||
// Don't add test settings to src/schemas/apiSchema.ts
|
||||
|
||||
// ❌ Don't chain through unknown to bypass types
|
||||
data as unknown as SomeType // Avoid; prefer explicit typings or helpers
|
||||
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
|
||||
```
|
||||
|
||||
### Accessing Internal State
|
||||
|
||||
@@ -36,6 +36,25 @@ When you must handle uncertain types, prefer these approaches in order:
|
||||
- Don't expose internal implementation types (e.g., Pinia store internals)
|
||||
- Reactive refs (`ComputedRef<T>`) should be unwrapped before exposing
|
||||
|
||||
## Avoiding Circular Dependencies
|
||||
|
||||
Extract type guards and their associated interfaces into **leaf modules** — files with only `import type` statements. This keeps them safe to import from anywhere without pulling in heavy transitive dependencies.
|
||||
|
||||
```typescript
|
||||
// ✅ myTypes.ts — leaf module (only type imports)
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface MyView extends IBaseWidget {
|
||||
/* ... */
|
||||
}
|
||||
export function isMyView(w: IBaseWidget): w is MyView {
|
||||
return 'myProp' in w
|
||||
}
|
||||
|
||||
// ❌ myView.ts — heavy module (runtime imports from stores, utils, etc.)
|
||||
// Importing the type guard from here drags in the entire dependency tree.
|
||||
```
|
||||
|
||||
## Utility Libraries
|
||||
|
||||
- Use `es-toolkit` for utility functions (not lodash)
|
||||
|
||||
@@ -6,10 +6,19 @@ export default {
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
|
||||
|
||||
const hasBrowserTestsChanges = stagedFiles
|
||||
.map((f) => path.relative(process.cwd(), f).replace(/\\/g, '/'))
|
||||
.some((f) => f.startsWith('browser_tests/'))
|
||||
|
||||
if (hasBrowserTestsChanges) {
|
||||
commands.push('pnpm typecheck:browser')
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.3",
|
||||
"version": "1.46.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -99,6 +99,10 @@
|
||||
--color-magenta-300: #ceaac9;
|
||||
--color-magenta-700: #6a246a;
|
||||
|
||||
--color-ocean-300: #badde8;
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
@@ -113,6 +117,7 @@
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
@@ -154,6 +159,7 @@
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--workflow-tabs-height: 2.375rem;
|
||||
--comfy-input-bg: #222;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
@@ -218,6 +224,10 @@
|
||||
--interface-panel-surface: var(--color-white);
|
||||
--interface-stroke: var(--color-smoke-300);
|
||||
|
||||
--interface-builder-mode-background: var(--color-ocean-300);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
--node-border: var(--color-smoke-300);
|
||||
@@ -377,6 +387,10 @@
|
||||
--interface-panel-surface: var(--color-charcoal-800);
|
||||
--interface-stroke: var(--color-charcoal-400);
|
||||
|
||||
--interface-builder-mode-background: var(--color-ocean-900);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
|
||||
--nav-background: var(--color-charcoal-800);
|
||||
|
||||
--node-border: var(--color-charcoal-500);
|
||||
@@ -412,6 +426,7 @@
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-white);
|
||||
@@ -510,6 +525,15 @@
|
||||
--color-comfy-menu-bg: var(--comfy-menu-bg);
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
|
||||
--color-interface-builder-mode-background: var(
|
||||
--interface-builder-mode-background
|
||||
);
|
||||
--color-interface-builder-mode-button-background: var(
|
||||
--interface-builder-mode-button-background
|
||||
);
|
||||
--color-interface-builder-mode-button-foreground: var(
|
||||
--interface-builder-mode-button-foreground
|
||||
);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
@@ -1877,3 +1901,22 @@ audio.comfy-audio.empty-audio-widget {
|
||||
margin-left: 15px;
|
||||
}
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
|
||||
/* ===================== Skeleton Shimmer ===================== */
|
||||
.skeleton-shimmer {
|
||||
background:
|
||||
linear-gradient(135deg, rgb(188 188 188 / 0.5) 0%, rgb(0 0 0 / 0.5) 100%) 0
|
||||
0 / 200% 200%,
|
||||
#000;
|
||||
animation: skeleton-shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
50% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
538
pnpm-lock.yaml
generated
@@ -11,7 +11,7 @@ import {
|
||||
} from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
|
||||
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
|
||||
import type { FormItem, SettingParams } from '../src/platform/settings/types'
|
||||
import type { SettingParams } from '../src/platform/settings/types'
|
||||
import type { ComfyCommandImpl } from '../src/stores/commandStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -123,8 +123,8 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
SERVER_CONFIG_ITEMS.map((config) => [
|
||||
normalizeI18nKey(config.id),
|
||||
{
|
||||
name: (config as unknown as FormItem).name,
|
||||
tooltip: (config as unknown as FormItem).tooltip
|
||||
name: config.name,
|
||||
tooltip: config.tooltip
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('downloadUtil', () => {
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
} as unknown as Response)
|
||||
} as Partial<Response> as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
|
||||
126
src/components/appMode/AppModeToolbar.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isWorkflowsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
appModeStore.setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
|
||||
}
|
||||
|
||||
function openTemplates() {
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 pointer-events-auto">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
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"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
<Button
|
||||
v-if="appModeStore.enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilderMode"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex flex-col w-10 rounded-lg bg-secondary-background overflow-hidden"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.templates'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.templates')"
|
||||
class="size-10"
|
||||
@click="openTemplates"
|
||||
>
|
||||
<i class="icon-[comfy--template] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +14,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@@ -41,7 +35,6 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
@@ -60,6 +53,7 @@ import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -77,12 +71,6 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -136,13 +124,6 @@ const items = computed(() => {
|
||||
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
@@ -185,14 +185,6 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
41
src/components/builder/BuilderDialog.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="header-icon" />
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
<slot name="title" />
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="-mr-1"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
117
src/components/builder/BuilderSaveDialogContent.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
</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') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in saveTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
:class="
|
||||
cn(
|
||||
itemClasses,
|
||||
openAsApp === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="openAsApp = option.value"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="openAsApp === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="onSave(filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { defaultFilename, onSave, onClose } = defineProps<{
|
||||
defaultFilename: string
|
||||
onSave: (filename: string, openAsApp: boolean) => void
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(true)
|
||||
|
||||
const saveTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
|
||||
const itemClasses =
|
||||
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
|
||||
</script>
|
||||
51
src/components/builder/BuilderSaveSuccessDialogContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<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>
|
||||
105
src/components/builder/BuilderToolbar.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
|
||||
:aria-label="t('builderToolbar.label')"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === step.id && 'bg-interface-builder-mode-background',
|
||||
activeStep !== step.id &&
|
||||
'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="appModeStore.setMode(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Save -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!appModeStore.hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'save'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="appModeStore.setBuilderSaving(true)"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
|
||||
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'),
|
||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||
}
|
||||
|
||||
const arrangeStep: BuilderToolbarStep<AppMode> = {
|
||||
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]'
|
||||
}
|
||||
</script>
|
||||
78
src/components/builder/ConnectOutputPopover.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="z-[1001] w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade"
|
||||
>
|
||||
<div class="flex h-12 items-center justify-between px-4">
|
||||
<h3 class="text-sm font-medium text-base-foreground">
|
||||
{{ t('builderToolbar.connectOutput') }}
|
||||
</h3>
|
||||
<PopoverClose
|
||||
:aria-label="t('g.close')"
|
||||
class="flex cursor-pointer appearance-none items-center justify-center border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</PopoverClose>
|
||||
</div>
|
||||
<div class="border-t border-border-default" />
|
||||
<p class="mt-3 px-4 text-xs text-muted-foreground leading-relaxed">
|
||||
{{ t('builderToolbar.connectOutputBody1') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!isSelectActive"
|
||||
class="mt-2 px-4 text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{{ t('builderToolbar.connectOutputBody2') }}
|
||||
</p>
|
||||
<div class="mt-4 flex items-center justify-end gap-2 px-4 pb-4">
|
||||
<template v-if="isSelectActive">
|
||||
<PopoverClose as-child>
|
||||
<Button variant="secondary" size="md">
|
||||
{{ t('g.ok') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</template>
|
||||
<template v-else>
|
||||
<PopoverClose as-child>
|
||||
<Button variant="muted-textonly" size="md">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose as-child>
|
||||
<Button variant="secondary" size="md" @click="emit('switch')">
|
||||
{{ t('builderToolbar.switchToSelect') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</template>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { isSelectActive = false } = defineProps<{
|
||||
isSelectActive?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
switch: []
|
||||
}>()
|
||||
</script>
|
||||
22
src/components/builder/StepBadge.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground"
|
||||
>
|
||||
<i v-if="modelValue === step.id" :class="cn(step.icon, 'size-5')" />
|
||||
<span v-else class="text-sm font-bold">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
|
||||
defineProps<{
|
||||
step: BuilderToolbarStep
|
||||
index: number
|
||||
modelValue: string
|
||||
}>()
|
||||
</script>
|
||||
20
src/components/builder/StepLabel.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-0.5 text-left">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ step.title }}
|
||||
</span>
|
||||
<span
|
||||
class="hidden whitespace-nowrap text-xs text-muted-foreground sm:inline"
|
||||
>
|
||||
{{ step.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
|
||||
const { step } = defineProps<{
|
||||
step: BuilderToolbarStep
|
||||
}>()
|
||||
</script>
|
||||
6
src/components/builder/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface BuilderToolbarStep<T extends string = string> {
|
||||
id: T
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: string
|
||||
}
|
||||
124
src/components/builder/useBuilderSave.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const appModeStore = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
watch(
|
||||
() => appModeStore.isBuilderSaving,
|
||||
(saving) => {
|
||||
if (saving) void onBuilderSave()
|
||||
}
|
||||
)
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) {
|
||||
resetSaving()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
|
||||
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
|
||||
|
||||
if (!workflow.isTemporary) {
|
||||
try {
|
||||
workflow.changeTracker?.checkState()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
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
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch {
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
component: BuilderSaveSuccessDialogContent,
|
||||
props: {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
appModeStore.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() {
|
||||
appModeStore.setBuilderSaving(false)
|
||||
}
|
||||
}
|
||||
77
src/components/common/WorkflowActionsDropdown.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
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"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
|
||||
>
|
||||
<WorkflowActionsList :items="menuItems" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
81
src/components/common/WorkflowActionsList.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import type {
|
||||
WorkflowMenuAction,
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
function createWrapper(items: WorkflowMenuItem[]) {
|
||||
return shallowMount(WorkflowActionsList, {
|
||||
props: { items },
|
||||
global: { renderStubDefaultSlot: true }
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowActionsList', () => {
|
||||
it('renders action items with label and icon', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).toContain('Save')
|
||||
expect(wrapper.find('.pi-save').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders separator items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ separator: true },
|
||||
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const html = wrapper.html()
|
||||
|
||||
expect(html).toContain('dropdown-menu-separator-stub')
|
||||
expect(wrapper.text()).toContain('Before')
|
||||
expect(wrapper.text()).toContain('After')
|
||||
})
|
||||
|
||||
it('dispatches command on select', async () => {
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
|
||||
await item.vm.$emit('select')
|
||||
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders badge when present', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
label: 'New Feature',
|
||||
icon: 'pi pi-star',
|
||||
command: vi.fn(),
|
||||
badge: 'NEW'
|
||||
}
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).toContain('NEW')
|
||||
})
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('NEW')
|
||||
})
|
||||
})
|
||||
53
src/components/common/WorkflowActionsList.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from 'reka-ui'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import OverlayIcon from '@/components/common/OverlayIcon.vue'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
items,
|
||||
itemComponent = DropdownMenuItem,
|
||||
separatorComponent = DropdownMenuSeparator
|
||||
} = defineProps<{
|
||||
items: WorkflowMenuItem[]
|
||||
itemComponent?: Component
|
||||
separatorComponent?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<component
|
||||
:is="separatorComponent"
|
||||
v-if="item.separator"
|
||||
class="border-b w-full border-border-subtle my-1"
|
||||
/>
|
||||
<component
|
||||
:is="itemComponent"
|
||||
v-else
|
||||
:disabled="item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-6 p-2 items-center gap-2 self-stretch rounded-sm outline-none',
|
||||
!item.disabled && item.command && 'cursor-pointer',
|
||||
'data-[highlighted]:bg-secondary-background-hover',
|
||||
!item.disabled && 'hover:bg-secondary-background-hover',
|
||||
'data-[disabled]:opacity-50 data-[disabled]:cursor-default'
|
||||
)
|
||||
"
|
||||
@select="() => item.command?.()"
|
||||
>
|
||||
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
|
||||
<i v-else-if="item.icon" :class="item.icon" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="rounded-full uppercase ml-3 flex items-center gap-1 bg-[var(--primary-background)] px-1.5 py-0.5 text-xxs text-base-foreground"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
</template>
|
||||
@@ -267,6 +267,16 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="template.vram" #top-left>
|
||||
<SquareChip
|
||||
:label="formatSize(template.vram)"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu] h-3 w-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
@@ -387,6 +397,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -724,6 +735,10 @@ const sortOptions = computed(() => [
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
|
||||
value: 'similar-to-current'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
|
||||
367
src/components/developerProfile/DeveloperProfileDialog.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const stubProfile: DeveloperProfile = {
|
||||
username: '@StoneCypher',
|
||||
displayName: 'Stone Cypher',
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: 'Workflow designer',
|
||||
isVerified: true,
|
||||
monetizationEnabled: true,
|
||||
joinedAt: new Date('2024-03-15'),
|
||||
dependencies: 371,
|
||||
totalDownloads: 1000,
|
||||
totalFavorites: 50,
|
||||
averageRating: 4.2,
|
||||
templateCount: 2
|
||||
}
|
||||
|
||||
const stubReviews: TemplateReview[] = [
|
||||
{
|
||||
id: 'rev-1',
|
||||
authorName: 'Reviewer',
|
||||
rating: 4.5,
|
||||
text: 'Great work!',
|
||||
createdAt: new Date('2025-10-01'),
|
||||
templateId: 'tpl-1'
|
||||
}
|
||||
]
|
||||
|
||||
const stubTemplate: MarketplaceTemplate = {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Desc',
|
||||
shortDescription: 'Short',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Stone Cypher',
|
||||
isVerified: true,
|
||||
profileUrl: '/u'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 500,
|
||||
favorites: 30,
|
||||
rating: 4,
|
||||
reviewCount: 5,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue[] = [
|
||||
{
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 5000,
|
||||
monthlyRevenue: 500,
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
|
||||
const mockService = vi.hoisted(() => ({
|
||||
getCurrentUsername: vi.fn(() => '@StoneCypher'),
|
||||
fetchDeveloperProfile: vi.fn(() => Promise.resolve({ ...stubProfile })),
|
||||
fetchDeveloperReviews: vi.fn(() => Promise.resolve([...stubReviews])),
|
||||
fetchPublishedTemplates: vi.fn(() => Promise.resolve([{ ...stubTemplate }])),
|
||||
fetchTemplateRevenue: vi.fn(() => Promise.resolve([...stubRevenue])),
|
||||
fetchDownloadHistory: vi.fn(() => Promise.resolve([])),
|
||||
unpublishTemplate: vi.fn(() => Promise.resolve()),
|
||||
saveDeveloperProfile: vi.fn((p: Partial<DeveloperProfile>) =>
|
||||
Promise.resolve({ ...stubProfile, ...p })
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/developerProfileService', () => mockService)
|
||||
|
||||
import DeveloperProfileDialog from './DeveloperProfileDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
dialogTitle: 'Developer Profile',
|
||||
username: 'Username',
|
||||
bio: 'Bio',
|
||||
reviews: 'Reviews',
|
||||
publishedTemplates: 'Published Templates',
|
||||
dependencies: 'Dependencies',
|
||||
totalDownloads: 'Downloads',
|
||||
totalFavorites: 'Favorites',
|
||||
averageRating: 'Avg. Rating',
|
||||
templateCount: 'Templates',
|
||||
revenue: 'Revenue',
|
||||
monthlyRevenue: 'Monthly',
|
||||
totalRevenue: 'Total',
|
||||
noReviews: 'No reviews yet',
|
||||
noTemplates: 'No published templates yet',
|
||||
unpublish: 'Unpublish',
|
||||
save: 'Save Profile',
|
||||
saving: 'Saving...',
|
||||
verified: 'Verified',
|
||||
quickActions: 'Quick Actions',
|
||||
bannerPlaceholder: 'Banner image',
|
||||
editUsername: 'Edit username',
|
||||
editBio: 'Edit bio',
|
||||
lookupHandle: 'Enter developer handle\u2026',
|
||||
downloads: 'Downloads',
|
||||
favorites: 'Favorites',
|
||||
rating: 'Rating'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { username?: string }) {
|
||||
return mount(DeveloperProfileDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
ReviewCard: {
|
||||
template: '<div data-testid="review-card" />',
|
||||
props: ['review']
|
||||
},
|
||||
TemplateListItem: {
|
||||
template:
|
||||
'<div data-testid="template-list-item" :data-show-revenue="showRevenue" :data-is-current-user="isCurrentUser" />',
|
||||
props: ['template', 'revenue', 'showRevenue', 'isCurrentUser']
|
||||
},
|
||||
DownloadHistoryChart: {
|
||||
template: '<div data-testid="download-history-chart" />',
|
||||
props: ['entries']
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'size', 'disabled']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeveloperProfileDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockService.getCurrentUsername.mockReturnValue('@StoneCypher')
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue({ ...stubProfile })
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([...stubReviews])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([{ ...stubTemplate }])
|
||||
mockService.fetchTemplateRevenue.mockResolvedValue([...stubRevenue])
|
||||
})
|
||||
|
||||
it('renders the banner section', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="banner-section"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows username input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows username text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows bio input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-input"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows bio text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-text"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders review cards', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="review-card"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders template list items', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="template-list-item"]')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('passes showRevenue=true when current user with monetization', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes showRevenue=false when not current user', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows quick actions when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides quick actions when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows save button when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('hides save button when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).not.toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('renders summary stats', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const stats = wrapper.find('[data-testid="summary-stats"]')
|
||||
expect(stats.exists()).toBe(true)
|
||||
expect(stats.text()).toContain('371')
|
||||
expect(stats.text()).toContain('1,000')
|
||||
expect(stats.text()).toContain('50')
|
||||
})
|
||||
|
||||
it('renders the handle input with the default username', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
expect(handleInput.exists()).toBe(true)
|
||||
expect((handleInput.element as HTMLInputElement).value).toBe('@StoneCypher')
|
||||
})
|
||||
|
||||
it('reloads data when the handle input changes', async () => {
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@OtherDev',
|
||||
displayName: 'Other Dev',
|
||||
bio: 'Another developer',
|
||||
isVerified: false,
|
||||
monetizationEnabled: false,
|
||||
totalDownloads: 42
|
||||
}
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Initial load
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith(
|
||||
'@StoneCypher'
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@OtherDev')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith('@OtherDev')
|
||||
expect(wrapper.find('[data-testid="username-text"]').text()).toBe(
|
||||
'Other Dev'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears revenue when switching to a non-current-user handle', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Revenue was loaded for current user
|
||||
expect(mockService.fetchTemplateRevenue).toHaveBeenCalled()
|
||||
vi.clearAllMocks()
|
||||
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@Someone',
|
||||
monetizationEnabled: false
|
||||
}
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@Someone')
|
||||
await flushPromises()
|
||||
|
||||
// Revenue should NOT be fetched for other user
|
||||
expect(mockService.fetchTemplateRevenue).not.toHaveBeenCalled()
|
||||
// showRevenue should be false
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
322
src/components/developerProfile/DeveloperProfileDialog.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<BaseModalLayout :content-title="t('developerProfile.dialogTitle')" size="sm">
|
||||
<template #header>
|
||||
<input
|
||||
v-model="viewedUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.lookupHandle')"
|
||||
class="h-8 w-48 rounded border border-border-default bg-secondary-background px-2 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="handle-input"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="isCurrentUser" #header-right-area>
|
||||
<div class="mr-6">
|
||||
<Button size="lg" :disabled="isSaving" @click="saveProfile">
|
||||
{{
|
||||
isSaving ? t('developerProfile.saving') : t('developerProfile.save')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Banner Image -->
|
||||
<div
|
||||
class="h-48 w-full overflow-hidden rounded-lg bg-secondary-background"
|
||||
data-testid="banner-section"
|
||||
>
|
||||
<img
|
||||
v-if="profile?.bannerUrl"
|
||||
:src="profile.bannerUrl"
|
||||
:alt="t('developerProfile.bannerPlaceholder')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i
|
||||
class="icon-[lucide--image] size-10 text-muted-foreground opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar + Username + Bio -->
|
||||
<div class="flex items-start gap-4" data-testid="identity-section">
|
||||
<div
|
||||
class="flex size-16 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="isCurrentUser">
|
||||
<input
|
||||
v-model="editableUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.editUsername')"
|
||||
class="h-8 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
data-testid="username-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-lg font-semibold" data-testid="username-text">
|
||||
{{ profile?.displayName ?? viewedUsername }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="profile?.isVerified"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-xs text-blue-400"
|
||||
>
|
||||
<i class="icon-[lucide--badge-check] size-3" />
|
||||
{{ t('developerProfile.verified') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isCurrentUser">
|
||||
<textarea
|
||||
v-model="editableBio"
|
||||
:placeholder="t('developerProfile.editBio')"
|
||||
rows="2"
|
||||
class="resize-none rounded border border-border-default bg-secondary-background px-2 py-1 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="bio-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="profile?.bio"
|
||||
class="m-0 text-sm text-muted-foreground"
|
||||
data-testid="bio-text"
|
||||
>
|
||||
{{ profile.bio }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-5 gap-3" data-testid="summary-stats">
|
||||
<div
|
||||
v-for="stat in summaryStats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col items-center rounded-lg bg-secondary-background p-3"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{ stat.value }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download History Chart -->
|
||||
<DownloadHistoryChart
|
||||
v-if="downloadHistory.length > 0"
|
||||
:entries="downloadHistory"
|
||||
/>
|
||||
|
||||
<!-- Quick Actions (current user only) -->
|
||||
<div
|
||||
v-if="isCurrentUser"
|
||||
class="rounded-lg border border-border-default p-4"
|
||||
data-testid="quick-actions"
|
||||
>
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.quickActions') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="handleUnpublish(tpl.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}: {{ tpl.title }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews Section -->
|
||||
<div data-testid="reviews-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.reviews') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="reviews.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noReviews') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-80 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<ReviewCard
|
||||
v-for="review in reviews"
|
||||
:key="review.id"
|
||||
:review="review"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Published Templates Section -->
|
||||
<div data-testid="templates-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.publishedTemplates') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="templates.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noTemplates') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-96 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<TemplateListItem
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:template="tpl"
|
||||
:revenue="revenueByTemplateId[tpl.id]"
|
||||
:show-revenue="showRevenueColumn"
|
||||
:is-current-user="isCurrentUser"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import {
|
||||
fetchDeveloperProfile,
|
||||
fetchDeveloperReviews,
|
||||
fetchDownloadHistory,
|
||||
fetchPublishedTemplates,
|
||||
fetchTemplateRevenue,
|
||||
getCurrentUsername,
|
||||
saveDeveloperProfile,
|
||||
unpublishTemplate
|
||||
} from '@/services/developerProfileService'
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
DownloadHistoryEntry,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
const { onClose, username } = defineProps<{
|
||||
onClose: () => void
|
||||
username?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const viewedUsername = ref(username ?? getCurrentUsername())
|
||||
const isCurrentUser = computed(
|
||||
() => viewedUsername.value === getCurrentUsername()
|
||||
)
|
||||
|
||||
const profile = ref<DeveloperProfile | null>(null)
|
||||
const reviews = ref<TemplateReview[]>([])
|
||||
const templates = ref<MarketplaceTemplate[]>([])
|
||||
const revenue = ref<TemplateRevenue[]>([])
|
||||
const downloadHistory = ref<DownloadHistoryEntry[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const editableUsername = ref('')
|
||||
const editableBio = ref('')
|
||||
|
||||
const revenueByTemplateId = computed(() => {
|
||||
const map: Record<string, TemplateRevenue> = {}
|
||||
for (const entry of revenue.value) {
|
||||
map[entry.templateId] = entry
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const showRevenueColumn = computed(
|
||||
() => isCurrentUser.value && (profile.value?.monetizationEnabled ?? false)
|
||||
)
|
||||
|
||||
const summaryStats = computed(() => [
|
||||
{
|
||||
label: t('developerProfile.dependencies'),
|
||||
value: (profile.value?.dependencies ?? 371).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalDownloads'),
|
||||
value: (profile.value?.totalDownloads ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalFavorites'),
|
||||
value: (profile.value?.totalFavorites ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.averageRating'),
|
||||
value: (profile.value?.averageRating ?? 0).toFixed(1)
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.templateCount'),
|
||||
value: String(profile.value?.templateCount ?? 0)
|
||||
}
|
||||
])
|
||||
|
||||
watchDebounced(viewedUsername, () => void loadData(), { debounce: 500 })
|
||||
|
||||
async function loadData() {
|
||||
const handle = viewedUsername.value
|
||||
const [profileData, reviewsData, templatesData, historyData] =
|
||||
await Promise.all([
|
||||
fetchDeveloperProfile(handle),
|
||||
fetchDeveloperReviews(handle),
|
||||
fetchPublishedTemplates(handle),
|
||||
fetchDownloadHistory(handle)
|
||||
])
|
||||
profile.value = profileData
|
||||
reviews.value = reviewsData
|
||||
templates.value = templatesData
|
||||
downloadHistory.value = historyData
|
||||
editableUsername.value = profileData.displayName
|
||||
editableBio.value = profileData.bio ?? ''
|
||||
|
||||
if (isCurrentUser.value && profileData.monetizationEnabled) {
|
||||
revenue.value = await fetchTemplateRevenue(handle)
|
||||
} else {
|
||||
revenue.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
profile.value = await saveDeveloperProfile({
|
||||
...profile.value,
|
||||
displayName: editableUsername.value,
|
||||
bio: editableBio.value
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish(templateId: string) {
|
||||
await unpublishTemplate(templateId)
|
||||
templates.value = templates.value.filter((t) => t.id !== templateId)
|
||||
}
|
||||
|
||||
void loadData()
|
||||
</script>
|
||||
217
src/components/developerProfile/DownloadHistoryChart.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DownloadHistoryEntry } from '@/types/templateMarketplace'
|
||||
|
||||
const { MockChart } = vi.hoisted(() => {
|
||||
const mockDestroyFn = vi.fn()
|
||||
|
||||
class MockChartClass {
|
||||
static register = vi.fn()
|
||||
static instances: MockChartClass[] = []
|
||||
type: string
|
||||
data: unknown
|
||||
destroy = mockDestroyFn
|
||||
|
||||
constructor(_canvas: unknown, config: { type: string; data: unknown }) {
|
||||
this.type = config.type
|
||||
this.data = config.data
|
||||
MockChartClass.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { MockChart: MockChartClass, mockDestroyFn }
|
||||
})
|
||||
|
||||
vi.mock('chart.js', () => ({
|
||||
Chart: MockChart,
|
||||
BarController: {},
|
||||
BarElement: {},
|
||||
CategoryScale: {},
|
||||
Filler: {},
|
||||
LineController: {},
|
||||
LineElement: {},
|
||||
LinearScale: {},
|
||||
PointElement: {},
|
||||
Tooltip: {}
|
||||
}))
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
downloadHistory: 'Download History',
|
||||
range: {
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
year: 'Year',
|
||||
allTime: 'All Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntries(count: number): DownloadHistoryEntry[] {
|
||||
const entries: DownloadHistoryEntry[] = []
|
||||
const now = new Date()
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
entries.push({ date, downloads: 10 + i })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function mountChart(entries?: DownloadHistoryEntry[]) {
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries: entries ?? makeEntries(730) },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function lastInstance() {
|
||||
return MockChart.instances.at(-1)
|
||||
}
|
||||
|
||||
describe('DownloadHistoryChart', () => {
|
||||
beforeEach(() => {
|
||||
MockChart.instances = []
|
||||
})
|
||||
|
||||
it('renders all four range buttons', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const buttons = wrapper.find('[data-testid="range-buttons"]')
|
||||
expect(buttons.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="range-btn-week"]').text()).toBe('Week')
|
||||
expect(wrapper.find('[data-testid="range-btn-month"]').text()).toBe('Month')
|
||||
expect(wrapper.find('[data-testid="range-btn-year"]').text()).toBe('Year')
|
||||
expect(wrapper.find('[data-testid="range-btn-allTime"]').text()).toBe(
|
||||
'All Time'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to week range with active styling', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const weekBtn = wrapper.find('[data-testid="range-btn-week"]')
|
||||
expect(weekBtn.classes()).toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('creates a bar chart for week range', async () => {
|
||||
await mountChart()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to month and creates a bar chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to year and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('switches to allTime and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('destroys previous chart when switching ranges', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const firstInstance = lastInstance()!
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(firstInstance.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the heading text', async () => {
|
||||
const wrapper = await mountChart()
|
||||
expect(wrapper.text()).toContain('Download History')
|
||||
})
|
||||
|
||||
it('passes 7 data points for week range', async () => {
|
||||
await mountChart()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('passes 31 data points for month range', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(31)
|
||||
})
|
||||
|
||||
it('downsamples year range to weekly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 365 days / 7 per bucket = 52 full + 1 partial = 53
|
||||
expect(labels).toHaveLength(Math.ceil(365 / 7))
|
||||
})
|
||||
|
||||
it('downsamples allTime range to monthly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 730 days / 30 per bucket = 24 full + 1 partial = 25
|
||||
expect(labels).toHaveLength(Math.ceil(730 / 30))
|
||||
})
|
||||
|
||||
it('sums downloads within each aggregated bucket', async () => {
|
||||
// 14 entries with downloads = 1 each, aggregated by 7 → 2 buckets of 7
|
||||
const entries = makeEntries(14).map((e) => ({ ...e, downloads: 1 }))
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
const chart = lastInstance()!
|
||||
const datasets = (chart.data as { datasets: { data: number[] }[] }).datasets
|
||||
// 14 / 30 per bucket → 1 bucket with all 14 summed
|
||||
expect(datasets[0].data).toEqual([14])
|
||||
})
|
||||
})
|
||||
209
src/components/developerProfile/DownloadHistoryChart.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg bg-secondary-background p-4"
|
||||
data-testid="download-history-chart"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-sm font-semibold">
|
||||
{{ t('developerProfile.downloadHistory') }}
|
||||
</h3>
|
||||
<div
|
||||
class="flex gap-1 rounded-md bg-modal-panel-background p-0.5"
|
||||
data-testid="range-buttons"
|
||||
>
|
||||
<button
|
||||
v-for="range in RANGES"
|
||||
:key="range"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded border-none px-2 py-1 text-xs transition-colors',
|
||||
selectedRange === range
|
||||
? 'bg-secondary-background font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)
|
||||
"
|
||||
:data-testid="`range-btn-${range}`"
|
||||
@click="selectedRange = range"
|
||||
>
|
||||
{{ t(`developerProfile.range.${range}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-62.5">
|
||||
<canvas ref="canvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
/** * Download history chart for the developer profile dashboard. * * Renders
|
||||
daily download counts using Chart.js, with a toggle group in the * upper-right
|
||||
corner that switches between four time ranges: * * - **Week** (7 bars) and
|
||||
**Month** (31 bars) render as bar charts. * - **Year** (weekly buckets) and
|
||||
**All Time** (monthly buckets) render as * filled area charts, with entries
|
||||
aggregated into summed buckets to keep * the point count manageable. * * @prop
|
||||
entries - Chronologically-ordered daily download history produced by * {@link
|
||||
fetchDownloadHistory}. */
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
DownloadHistoryEntry,
|
||||
DownloadHistoryRange
|
||||
} from '@/types/templateMarketplace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
const RANGES: DownloadHistoryRange[] = ['week', 'month', 'year', 'allTime']
|
||||
|
||||
const BAR_COLOR = '#185A8B'
|
||||
|
||||
const { entries } = defineProps<{
|
||||
entries: DownloadHistoryEntry[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedRange = ref<DownloadHistoryRange>('week')
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const chartInstance = shallowRef<Chart | null>(null)
|
||||
|
||||
/**
|
||||
* Aggregates entries into buckets by summing downloads and using the last
|
||||
* date in each bucket for the label.
|
||||
*/
|
||||
function aggregate(
|
||||
source: DownloadHistoryEntry[],
|
||||
bucketSize: number
|
||||
): DownloadHistoryEntry[] {
|
||||
const result: DownloadHistoryEntry[] = []
|
||||
for (let i = 0; i < source.length; i += bucketSize) {
|
||||
const bucket = source.slice(i, i + bucketSize)
|
||||
const downloads = bucket.reduce((sum, e) => sum + e.downloads, 0)
|
||||
result.push({ date: bucket[bucket.length - 1].date, downloads })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tail slice of entries matching the selected range, downsampled
|
||||
* for larger views, along with formatted date labels.
|
||||
*/
|
||||
function sliceEntries(range: DownloadHistoryRange): {
|
||||
labels: string[]
|
||||
data: number[]
|
||||
} {
|
||||
const count =
|
||||
range === 'week' ? 7 : range === 'month' ? 31 : range === 'year' ? 365 : 0
|
||||
|
||||
const sliced = count > 0 ? entries.slice(-count) : entries
|
||||
const sampled =
|
||||
range === 'year'
|
||||
? aggregate(sliced, 7)
|
||||
: range === 'allTime'
|
||||
? aggregate(sliced, 30)
|
||||
: sliced
|
||||
|
||||
const labels = sampled.map((e) => {
|
||||
const d = e.date
|
||||
if (range === 'week')
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
if (range === 'month') return String(d.getDate())
|
||||
if (range === 'year')
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
||||
})
|
||||
|
||||
return { labels, data: sampled.map((e) => e.downloads) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds or replaces the Chart.js instance on the canvas whenever range
|
||||
* or data changes.
|
||||
*/
|
||||
function renderChart() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const range = selectedRange.value
|
||||
const isBar = range === 'week' || range === 'month'
|
||||
const { labels, data } = sliceEntries(range)
|
||||
|
||||
chartInstance.value = new Chart(canvas, {
|
||||
type: isBar ? 'bar' : 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: isBar ? BAR_COLOR : `${BAR_COLOR}33`,
|
||||
borderColor: BAR_COLOR,
|
||||
borderWidth: isBar ? 0 : 2,
|
||||
borderRadius: isBar ? { topLeft: 4, topRight: 4 } : undefined,
|
||||
fill: !isBar,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
transitions: { active: { animation: { duration: 0 } } },
|
||||
plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#9FA2BD',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isBar ? undefined : 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#9FA2BD22' },
|
||||
ticks: { color: '#9FA2BD' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch([selectedRange, () => entries], renderChart, { flush: 'post' })
|
||||
|
||||
watch(canvasRef, (el) => {
|
||||
if (el) renderChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
63
src/components/developerProfile/ReviewCard.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
|
||||
function makeReview(overrides?: Partial<TemplateReview>): TemplateReview {
|
||||
return {
|
||||
id: 'rev-1',
|
||||
authorName: 'TestUser',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 4,
|
||||
text: 'Great template!',
|
||||
createdAt: new Date('2025-10-15'),
|
||||
templateId: 'tpl-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(review: TemplateReview) {
|
||||
return mount(ReviewCard, {
|
||||
props: { review },
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" :data-rating="rating" />',
|
||||
props: ['rating', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ReviewCard', () => {
|
||||
it('renders the author name', () => {
|
||||
const wrapper = mountCard(makeReview({ authorName: 'PixelWizard' }))
|
||||
expect(wrapper.text()).toContain('PixelWizard')
|
||||
})
|
||||
|
||||
it('renders the review text', () => {
|
||||
const wrapper = mountCard(makeReview({ text: 'Awesome workflow!' }))
|
||||
expect(wrapper.text()).toContain('Awesome workflow!')
|
||||
})
|
||||
|
||||
it('passes the rating to StarRating', () => {
|
||||
const wrapper = mountCard(makeReview({ rating: 3.5 }))
|
||||
const starRating = wrapper.find('[data-testid="star-rating"]')
|
||||
expect(starRating.exists()).toBe(true)
|
||||
expect(starRating.attributes('data-rating')).toBe('3.5')
|
||||
})
|
||||
|
||||
it('renders a formatted date', () => {
|
||||
const wrapper = mountCard(makeReview({ createdAt: new Date('2025-10-15') }))
|
||||
expect(wrapper.text()).toContain('2025')
|
||||
})
|
||||
})
|
||||
38
src/components/developerProfile/ReviewCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 rounded-lg bg-secondary-background p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span class="text-sm font-medium">{{ review.authorName }}</span>
|
||||
<StarRating :rating="review.rating" size="sm" />
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ review.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const { review } = defineProps<{
|
||||
/** The review to display. */
|
||||
review: TemplateReview
|
||||
}>()
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
review.createdAt.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
)
|
||||
</script>
|
||||
72
src/components/developerProfile/StarRating.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
function mountRating(rating: number, size?: 'sm' | 'md') {
|
||||
return mount(StarRating, {
|
||||
props: { rating, size }
|
||||
})
|
||||
}
|
||||
|
||||
describe('StarRating', () => {
|
||||
it('renders five star containers', () => {
|
||||
const wrapper = mountRating(3)
|
||||
const starContainers = wrapper.findAll('[role="img"] > div')
|
||||
expect(starContainers).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('fills all stars for a rating of 5', () => {
|
||||
const wrapper = mountRating(5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(5)
|
||||
for (const fill of fills) {
|
||||
expect(fill.attributes('style')).toContain('width: 100%')
|
||||
}
|
||||
})
|
||||
|
||||
it('fills no stars for a rating of 0', () => {
|
||||
const wrapper = mountRating(0)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 3.5', () => {
|
||||
const wrapper = mountRating(3.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(4)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[3].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 2.5', () => {
|
||||
const wrapper = mountRating(2.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(3)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('uses smaller size class when size is sm', () => {
|
||||
const wrapper = mountRating(3, 'sm')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-3.5')
|
||||
})
|
||||
|
||||
it('uses default size class when size is md', () => {
|
||||
const wrapper = mountRating(3, 'md')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-4')
|
||||
})
|
||||
})
|
||||
56
src/components/developerProfile/StarRating.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5"
|
||||
role="img"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div v-for="i in 5" :key="i" class="relative" :class="starSizeClass">
|
||||
<i
|
||||
:class="
|
||||
cn('icon-[lucide--star]', starSizeClass, 'text-muted-foreground')
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="fillWidth(i) > 0"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
:style="{ width: `${fillWidth(i)}%` }"
|
||||
>
|
||||
<i
|
||||
:class="cn('icon-[lucide--star]', starSizeClass, 'text-amber-400')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { rating, size = 'md' } = defineProps<{
|
||||
/** Star rating value from 0 to 5, supporting 0.5 increments. */
|
||||
rating: number
|
||||
/** Visual size variant. */
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const starSizeClass = computed(() => (size === 'sm' ? 'size-3.5' : 'size-4'))
|
||||
|
||||
const ariaLabel = computed(
|
||||
() => t('developerProfile.rating') + ': ' + String(rating) + '/5'
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the fill percentage (0, 50, or 100) for the star at position `i`.
|
||||
* @param i - 1-indexed star position.
|
||||
*/
|
||||
function fillWidth(i: number): number {
|
||||
if (rating >= i) return 100
|
||||
if (rating >= i - 0.5) return 50
|
||||
return 0
|
||||
}
|
||||
</script>
|
||||
154
src/components/developerProfile/TemplateListItem.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
function makeTemplate(
|
||||
overrides?: Partial<MarketplaceTemplate>
|
||||
): MarketplaceTemplate {
|
||||
return {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Full description',
|
||||
shortDescription: 'Short desc',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Author',
|
||||
isVerified: true,
|
||||
profileUrl: '/author'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 1000,
|
||||
favorites: 50,
|
||||
rating: 4.5,
|
||||
reviewCount: 10,
|
||||
weeklyTrend: 2
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue = {
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 10_000,
|
||||
monthlyRevenue: 1_500,
|
||||
currency: 'USD'
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
template?: MarketplaceTemplate
|
||||
revenue?: TemplateRevenue
|
||||
showRevenue?: boolean
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
function mountItem(options: MountOptions = {}) {
|
||||
return mount(TemplateListItem, {
|
||||
props: {
|
||||
template: options.template ?? makeTemplate(),
|
||||
revenue: options.revenue,
|
||||
showRevenue: options.showRevenue ?? false,
|
||||
isCurrentUser: options.isCurrentUser ?? false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" />',
|
||||
props: ['rating', 'size']
|
||||
},
|
||||
Button: {
|
||||
template: '<button data-testid="unpublish-button"><slot /></button>',
|
||||
props: ['variant', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplateListItem', () => {
|
||||
it('renders the template title and description', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
title: 'My Workflow',
|
||||
shortDescription: 'A cool workflow'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Workflow')
|
||||
expect(wrapper.text()).toContain('A cool workflow')
|
||||
})
|
||||
|
||||
it('renders download and favorite stats', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
stats: {
|
||||
downloads: 5_000,
|
||||
favorites: 200,
|
||||
rating: 4,
|
||||
reviewCount: 15,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
expect(wrapper.text()).toContain('200')
|
||||
})
|
||||
|
||||
it('hides revenue column when showRevenue is false', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: false
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows revenue column when showRevenue is true', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: true
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides unpublish button when isCurrentUser is false', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: false })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('shows unpublish button when isCurrentUser is true', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits unpublish event with template ID when button is clicked', async () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
await wrapper.find('[data-testid="unpublish-button"]').trigger('click')
|
||||
expect(wrapper.emitted('unpublish')).toEqual([['tpl-1']])
|
||||
})
|
||||
})
|
||||
114
src/components/developerProfile/TemplateListItem.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-lg bg-secondary-background p-3"
|
||||
data-testid="template-list-item"
|
||||
>
|
||||
<div
|
||||
class="size-12 shrink-0 overflow-hidden rounded bg-modal-panel-background"
|
||||
>
|
||||
<img
|
||||
v-if="template.thumbnail"
|
||||
:src="template.thumbnail"
|
||||
:alt="template.title"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i class="icon-[lucide--image] size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="m-0 truncate text-sm font-medium">{{ template.title }}</h4>
|
||||
<p class="m-0 truncate text-xs text-muted-foreground">
|
||||
{{ template.shortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.downloads')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-3.5" />
|
||||
{{ template.stats.downloads.toLocaleString() }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.favorites')"
|
||||
>
|
||||
<i class="icon-[lucide--heart] size-3.5" />
|
||||
{{ template.stats.favorites.toLocaleString() }}
|
||||
</span>
|
||||
<StarRating :rating="template.stats.rating" size="sm" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRevenue && revenue"
|
||||
class="shrink-0 text-right text-xs"
|
||||
data-testid="revenue-column"
|
||||
>
|
||||
<div class="font-medium">{{ formatCurrency(revenue.totalRevenue) }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ formatCurrency(revenue.monthlyRevenue) }}/{{
|
||||
t('developerProfile.monthlyRevenue').toLowerCase()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isCurrentUser"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
data-testid="unpublish-button"
|
||||
@click="emit('unpublish', template.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const {
|
||||
template,
|
||||
revenue,
|
||||
showRevenue = false,
|
||||
isCurrentUser = false
|
||||
} = defineProps<{
|
||||
/** The template to display. */
|
||||
template: MarketplaceTemplate
|
||||
/** Revenue data for this template, shown when showRevenue is true. */
|
||||
revenue?: TemplateRevenue
|
||||
/** Whether to display the revenue column. */
|
||||
showRevenue?: boolean
|
||||
/** Whether the profile being viewed belongs to the current user. */
|
||||
isCurrentUser?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when the unpublish button is clicked. */
|
||||
unpublish: [templateId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Formats a value in cents as a currency string.
|
||||
* @param cents - Amount in cents.
|
||||
*/
|
||||
function formatCurrency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="missing-nodes-warning"
|
||||
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||
:class="isCloud ? 'border-b' : ''"
|
||||
>
|
||||
|
||||
209
src/components/graph/DomWidgets.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
type TestWidget = BaseDOMWidget<object | string>
|
||||
|
||||
function createNode(
|
||||
graph: LGraph,
|
||||
id: number,
|
||||
title: string,
|
||||
pos: [number, number]
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
node.id = id
|
||||
node.pos = [...pos]
|
||||
node.size = [240, 120]
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
|
||||
return {
|
||||
id,
|
||||
node,
|
||||
name: 'test_widget',
|
||||
type: 'custom',
|
||||
value: '',
|
||||
options: {},
|
||||
y,
|
||||
width: 120,
|
||||
computedHeight: 40,
|
||||
margin: 10,
|
||||
isVisible: () => true
|
||||
} as unknown as TestWidget
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
return {
|
||||
graph,
|
||||
low_quality: false,
|
||||
read_only: false,
|
||||
isNodeVisible: vi.fn(() => true)
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
function drawFrame(canvas: LGraphCanvas) {
|
||||
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
|
||||
}
|
||||
|
||||
describe('DomWidgets transition grace characterization', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('applies transition grace for exactly one frame when override exists but is not active', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
|
||||
|
||||
const widget = createWidget('widget-transition', interiorNode, 14)
|
||||
const overrideWidget = createWidget('override-widget', overrideNode, 22)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
widgetState.visible = true
|
||||
widgetState.pos = [321, 654]
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([321, 654])
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
|
||||
|
||||
const widget = createWidget('widget-override-active', interiorNode, 8)
|
||||
const overrideWidget = createWidget(
|
||||
'override-position-source',
|
||||
overrideNode,
|
||||
18
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([310, 428])
|
||||
})
|
||||
|
||||
it('cleans orphaned transition-grace ids after widget removal', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
const overrideWidget = createWidget(
|
||||
'shared-override-widget',
|
||||
overrideNode,
|
||||
14
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(oldWidget)
|
||||
domWidgetStore.setPositionOverride(oldWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
domWidgetStore.unregisterWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
domWidgetStore.registerWidget(replacementWidget)
|
||||
domWidgetStore.setPositionOverride(replacementWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(replacementWidget.id)
|
||||
|
||||
const replacementState = domWidgetStore.widgetStates.get(
|
||||
replacementWidget.id
|
||||
)
|
||||
if (!replacementState) throw new Error('Replacement widget missing state')
|
||||
replacementState.visible = true
|
||||
replacementState.pos = [999, 999]
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(replacementState.visible).toBe(true)
|
||||
expect(replacementState.pos).toEqual([999, 999])
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,13 @@ import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
|
||||
import { getDomWidgetZIndex } from '@/components/graph/widgets/domWidgetZIndex'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const overrideTransitionGrace = new Set<string>()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
|
||||
@@ -29,37 +31,76 @@ const updateWidgets = () => {
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
const seenWidgetIds = new Set<string>()
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
seenWidgetIds.add(widget.id)
|
||||
|
||||
// Early exit for non-visible widgets
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
// Use position override only when the override node (SubgraphNode) is
|
||||
// in the current graph. When the user enters the subgraph, the override
|
||||
// node is no longer visible — fall back to the widget's own node.
|
||||
// Use graph reference equality (IDs are not unique across graphs).
|
||||
const override = widgetState.positionOverride
|
||||
const useOverride = !!override && currentGraph === override.node.graph
|
||||
const inOverrideTransitionGap =
|
||||
!!override && !useOverride && !widgetState.active
|
||||
const useTransitionGrace =
|
||||
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
|
||||
|
||||
if (useTransitionGrace) {
|
||||
overrideTransitionGrace.add(widget.id)
|
||||
} else if (!inOverrideTransitionGap) {
|
||||
overrideTransitionGrace.delete(widget.id)
|
||||
}
|
||||
|
||||
// Early exit for non-visible widgets.
|
||||
// When a position override is active (widget promoted to SubgraphNode),
|
||||
// the interior widget's `active` flag is false (its node is in the
|
||||
// subgraph, not the current graph) — bypass that check.
|
||||
if (
|
||||
!widget.isVisible() ||
|
||||
(!widgetState.active && !useOverride && !useTransitionGrace)
|
||||
) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the widget's node is in the current graph
|
||||
const node = widget.node
|
||||
const isInCorrectGraph = currentGraph?.nodes.includes(node)
|
||||
// During graph transitions, hold the previous position for one frame
|
||||
// so promoted widgets don't briefly disappear before activation flips.
|
||||
if (useTransitionGrace) continue
|
||||
|
||||
const posNode = useOverride ? override.node : widget.node
|
||||
const posWidget = useOverride ? override.widget : widget
|
||||
|
||||
const isInCorrectGraph = posNode.graph === currentGraph
|
||||
const nodeVisible = lgCanvas.isNodeVisible(posNode)
|
||||
|
||||
widgetState.visible =
|
||||
!!isInCorrectGraph &&
|
||||
lgCanvas.isNodeVisible(node) &&
|
||||
isInCorrectGraph &&
|
||||
nodeVisible &&
|
||||
!(widget.options.hideOnZoom && lowQuality)
|
||||
|
||||
if (widgetState.visible && node) {
|
||||
if (widgetState.visible) {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
|
||||
widgetState.size = [
|
||||
(widget.width ?? node.width) - margin * 2,
|
||||
(widget.computedHeight ?? 50) - margin * 2
|
||||
widgetState.pos = [
|
||||
posNode.pos[0] + margin,
|
||||
posNode.pos[1] + margin + posWidget.y
|
||||
]
|
||||
// TODO: optimize this logic as it's O(n), where n is the number of nodes
|
||||
widgetState.zIndex = lgCanvas.graph?.nodes.indexOf(node) ?? -1
|
||||
widgetState.size = [
|
||||
(posWidget.width ?? posNode.width) - margin * 2,
|
||||
(posWidget.computedHeight ?? 50) - margin * 2
|
||||
]
|
||||
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
|
||||
widgetState.readonly = lgCanvas.read_only
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetId of overrideTransitionGrace) {
|
||||
if (!seenWidgetIds.has(widgetId)) {
|
||||
overrideTransitionGrace.delete(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<template v-if="showUI" #workflow-tabs>
|
||||
<div
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
|
||||
class="workflow-tabs-container pointer-events-auto relative w-full h-(--workflow-tabs-height)"
|
||||
>
|
||||
<!-- Native drag area for Electron -->
|
||||
<div
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI" #side-toolbar>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
@@ -31,19 +31,27 @@
|
||||
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI" #topmenu>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
|
||||
<TopMenuSection />
|
||||
</template>
|
||||
<template v-if="showUI" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<NodePropertiesPanel />
|
||||
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<MiniMap
|
||||
v-if="comfyAppReady && minimapEnabled && betaMenuEnabled"
|
||||
v-if="
|
||||
comfyAppReady &&
|
||||
minimapEnabled &&
|
||||
betaMenuEnabled &&
|
||||
!appModeStore.isBuilderMode
|
||||
"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</template>
|
||||
@@ -174,6 +182,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -194,6 +203,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
31
src/components/graph/VramEstimateIndicator.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
Floating indicator that displays the estimated VRAM requirement
|
||||
for the currently loaded workflow graph.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
v-if="vramEstimate > 0"
|
||||
class="pointer-events-auto absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/40 px-2.5 py-1.5 text-xs font-medium text-white/90 backdrop-blur-sm"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<i class="icon-[lucide--cpu] h-3.5 w-3.5" />
|
||||
{{ formatSize(vramEstimate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const vramEstimate = ref(0)
|
||||
|
||||
watchEffect(() => {
|
||||
vramEstimate.value = estimateWorkflowVram(app.rootGraph)
|
||||
})
|
||||
</script>
|
||||
@@ -68,7 +68,11 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
|
||||
const isSelected = selectedNode === ownerNode
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -143,8 +147,17 @@ onMounted(() => {
|
||||
widget.options.selectOn ?? ['focus', 'click'],
|
||||
() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
lgCanvas?.selectNode(widgetState.widget.node)
|
||||
lgCanvas?.bringToFront(widgetState.widget.node)
|
||||
if (!lgCanvas) return
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph
|
||||
? override.node
|
||||
: widgetState.widget.node
|
||||
|
||||
lgCanvas.selectNode(ownerNode)
|
||||
lgCanvas.bringToFront(ownerNode)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
37
src/components/graph/widgets/domWidgetZIndex.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { getDomWidgetZIndex } from './domWidgetZIndex'
|
||||
|
||||
describe('getDomWidgetZIndex', () => {
|
||||
it('follows graph node ordering when node.order is stale', () => {
|
||||
const graph = new LGraph()
|
||||
const first = new LGraphNode('first')
|
||||
const second = new LGraphNode('second')
|
||||
graph.add(first)
|
||||
graph.add(second)
|
||||
|
||||
first.order = 0
|
||||
second.order = 1
|
||||
|
||||
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
|
||||
nodes.splice(nodes.indexOf(first), 1)
|
||||
nodes.push(first)
|
||||
|
||||
expect(first.order).toBe(0)
|
||||
expect(second.order).toBe(1)
|
||||
|
||||
expect(getDomWidgetZIndex(first, graph)).toBe(1)
|
||||
expect(getDomWidgetZIndex(second, graph)).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to node.order when node is not in current graph', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('orphan')
|
||||
node.order = 7
|
||||
|
||||
expect(getDomWidgetZIndex(node, graph)).toBe(7)
|
||||
expect(getDomWidgetZIndex(node, undefined)).toBe(7)
|
||||
})
|
||||
})
|
||||
13
src/components/graph/widgets/domWidgetZIndex.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export function getDomWidgetZIndex(
|
||||
node: LGraphNode,
|
||||
currentGraph: LGraphNode['graph'] | undefined
|
||||
): number {
|
||||
if (!currentGraph) return node.order ?? -1
|
||||
|
||||
const graphOrder = currentGraph.nodes.indexOf(node)
|
||||
if (graphOrder === -1) return node.order ?? -1
|
||||
|
||||
return graphOrder
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import type { Ref } from 'vue'
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
|
||||
const mockProgress = vi.hoisted(() => ({
|
||||
totalPercent: null as unknown as Ref<number>,
|
||||
currentNodePercent: null as unknown as Ref<number>
|
||||
totalPercent: null! as Ref<number>,
|
||||
currentNodePercent: null! as Ref<number>
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useQueueProgress', () => ({
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('useJobErrorReporting', () => {
|
||||
dialog = {
|
||||
showErrorDialog,
|
||||
showExecutionErrorDialog
|
||||
} as unknown as JobErrorDialogService
|
||||
} as Partial<JobErrorDialogService> as JobErrorDialogService
|
||||
composable = useJobErrorReporting({
|
||||
taskForJob,
|
||||
copyToClipboard: copyToClipboard as (
|
||||
|
||||
@@ -8,7 +8,6 @@ import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import { st } from '@/i18n'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -106,7 +105,7 @@ const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
return executionErrorStore.hasInternalErrorForNode(node.id)
|
||||
return executionErrorStore.isContainerWithInternalError(node)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -229,12 +228,6 @@ function handleTitleEdit(newTitle: string) {
|
||||
function handleTitleCancel() {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
if (!selectedSingleNode.value) return
|
||||
;(selectedSingleNode.value as SubgraphNode).properties.proxyWidgets = value
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -330,7 +323,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
@update:proxy-widgets="handleProxyWidgetsUpdate"
|
||||
/>
|
||||
<TabNormalInputs
|
||||
v-else-if="activeTab === 'parameters'"
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getExecutionIdByNode,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -20,6 +21,7 @@ import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
@@ -200,26 +202,30 @@ export function useErrorGroups(
|
||||
const selectedNodeInfo = computed(() => {
|
||||
const items = canvasStore.selectedItems
|
||||
const nodeIds = new Set<string>()
|
||||
const containerIds = new Set<string>()
|
||||
const containerExecutionIds = new Set<NodeExecutionId>()
|
||||
|
||||
for (const item of items) {
|
||||
if (!isLGraphNode(item)) continue
|
||||
nodeIds.add(String(item.id))
|
||||
if (item instanceof SubgraphNode || isGroupNode(item)) {
|
||||
containerIds.add(String(item.id))
|
||||
if (
|
||||
(item instanceof SubgraphNode || isGroupNode(item)) &&
|
||||
app.rootGraph
|
||||
) {
|
||||
const execId = getExecutionIdByNode(app.rootGraph, item)
|
||||
if (execId) containerExecutionIds.add(execId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
||||
containerIds
|
||||
containerExecutionIds
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerIds.size === 0
|
||||
selectedNodeInfo.value.containerExecutionIds.size === 0
|
||||
)
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
@@ -238,8 +244,9 @@ export function useErrorGroups(
|
||||
const graphNode = errorNodeCache.value.get(executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
|
||||
for (const containerId of selectedNodeInfo.value.containerIds) {
|
||||
if (executionNodeId.startsWith(`${containerId}:`)) return true
|
||||
for (const containerExecId of selectedNodeInfo.value
|
||||
.containerExecutionIds) {
|
||||
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -3,10 +3,10 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
@@ -69,27 +69,28 @@ const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
if (!parents.length) return false
|
||||
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
|
||||
|
||||
// For proxy widgets (already promoted), check using overlay information
|
||||
if (isProxyWidget(widget)) {
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widget._overlay.nodeId == nodeId &&
|
||||
widget._overlay.widgetName === widgetName
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
}
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
String(widgetNode.id),
|
||||
widget.name
|
||||
)
|
||||
}
|
||||
|
||||
// For regular widgets (not yet promoted), check using node ID and widget name
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widgetNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
@@ -121,7 +122,7 @@ const hasContainerInternalError = computed(() => {
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionErrorStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
return executionErrorStore.isContainerWithInternalError(targetNode.value)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
|
||||
@@ -3,25 +3,22 @@ import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
triggerRef,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
@@ -32,12 +29,9 @@ const { node } = defineProps<{
|
||||
node: SubgraphNode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:proxyWidgets': [value: ProxyWidgetsProperty]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -46,18 +40,9 @@ const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
// Use customRef to track proxyWidgets changes
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
},
|
||||
set(value?: ProxyWidgetsProperty) {
|
||||
trigger()
|
||||
if (!value) return
|
||||
emit('update:proxyWidgets', value)
|
||||
}
|
||||
}))
|
||||
const promotionEntries = computed(() =>
|
||||
promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
)
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
@@ -81,22 +66,18 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const proxyWidgetsOrder = proxyWidgets.value
|
||||
const entries = promotionEntries.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
// Map proxyWidgets to actual proxy widgets in the correct order
|
||||
const result: NodeWidgetsList = []
|
||||
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
|
||||
// Find the proxy widget that matches this nodeId and widgetName
|
||||
for (const { interiorNodeId, widgetName } of entries) {
|
||||
const widget = widgets.find((w) => {
|
||||
// Check if this is a proxy widget with _overlay
|
||||
if (isProxyWidget(w)) {
|
||||
if (isPromotedWidgetView(w)) {
|
||||
return (
|
||||
String(w._overlay.nodeId) === nodeId &&
|
||||
w._overlay.widgetName === widgetName
|
||||
String(w.sourceNodeId) === interiorNodeId &&
|
||||
w.sourceWidgetName === widgetName
|
||||
)
|
||||
}
|
||||
// For non-proxy widgets (like linked widgets), match by name
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
@@ -108,9 +89,7 @@ const widgetsList = computed((): NodeWidgetsList => {
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
||||
|
||||
// Get all widgets from interior nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((interiorNode) => {
|
||||
const { widgets = [] } = interiorNode
|
||||
return widgets
|
||||
@@ -118,13 +97,15 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
.map((widget) => ({ node: interiorNode, widget }))
|
||||
})
|
||||
|
||||
// Filter out widgets that are already promoted using tuple matching
|
||||
return allInteriorWidgets.filter(({ node: interiorNode, widget }) => {
|
||||
return !proxyWidgetsValue.some(
|
||||
([nodeId, widgetName]) =>
|
||||
interiorNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
})
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const parents = computed<SubgraphNode[]>(() => [node])
|
||||
@@ -181,13 +162,13 @@ function setDraggableState() {
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
// Update proxyWidgets order
|
||||
const pw = proxyWidgets.value
|
||||
const [w] = pw.splice(oldPosition, 1)
|
||||
pw.splice(newPosition, 0, w)
|
||||
proxyWidgets.value = pw
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||