diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png index e0f46d3791..df2141ad73 100644 Binary files a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png differ diff --git a/browser_tests/tests/priceBadge.spec.ts b/browser_tests/tests/priceBadge.spec.ts new file mode 100644 index 0000000000..a9f8a8c4cb --- /dev/null +++ b/browser_tests/tests/priceBadge.spec.ts @@ -0,0 +1,41 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '@e2e/fixtures/ComfyPage' + +test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => { + const apiNodeName = 'Node With Price Badge' + await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)') + + const priceBadge = comfyPage.page.locator('.lg-node-header i + span') + const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName) + + await comfyPage.menu.topbar.newWorkflowButton.click() + await comfyPage.nextFrame() + + await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 }) + await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName) + await expect(comfyPage.searchBox.input).toBeHidden() + await expect(apiNode, 'Add partner node').toBeVisible() + await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible() + + await comfyPage.contextMenu + .openForVueNode(apiNode) + .then((m) => m.clickMenuItemExact('Convert to Subgraph')) + const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') + await expect(subgraphNode, 'Convert to Subgraph').toBeVisible() + + const nodePrice = subgraphNode.locator(priceBadge) + await expect(nodePrice, 'subgraphNode has price badge').toBeVisible() + const initialPrice = Number(await nodePrice.innerText()) + + await comfyPage.subgraph.editor.togglePromotion(subgraphNode, { + nodeName: apiNodeName, + widgetName: 'price', + toState: true + }) + await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x') + await expect(nodePrice, 'Price is reactive').toHaveText( + String(initialPrice * 2) + ) +}) diff --git a/src/components/node/CreditBadge.vue b/src/components/node/CreditBadge.vue index dc109e354f..a306eebafc 100644 --- a/src/components/node/CreditBadge.vue +++ b/src/components/node/CreditBadge.vue @@ -12,7 +12,7 @@ diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index f70c7da292..9f11de1b74 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -48,6 +48,8 @@ export interface WidgetSlotMetadata { type: string } +type Badges = (LGraphBadge | (() => LGraphBadge))[] + /** * Minimal render-specific widget data extracted from LiteGraph widgets. * Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore. @@ -107,7 +109,7 @@ export interface VueNodeData { title: string type: string apiNode?: boolean - badges?: (LGraphBadge | (() => LGraphBadge))[] + badges?: Badges bgcolor?: string color?: string flags?: { @@ -786,6 +788,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { showAdvanced: Boolean(propertyEvent.newValue) }) break + case 'badges': + vueNodeData.set(nodeId, { + ...currentData, + badges: propertyEvent.newValue as Badges + }) + break } } }, diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index e9e0d2bbe3..87090d9c22 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -628,9 +628,9 @@ describe('useNodePricing', () => { getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) - // VueNodes path bumps per-node ref instead of the global tick. + // VueNodes path bumps per-node ref and the global tick. expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore) - expect(pricingRevision.value).toBe(tickBefore) + expect(pricingRevision.value).toBeGreaterThan(tickBefore) } finally { LiteGraph.vueNodesMode = false } diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index fb7f417062..0dfb8ed4f2 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -524,10 +524,8 @@ function scheduleEvaluation( if (LiteGraph.vueNodesMode) { // VueNodes mode: bump per-node revision (only this node re-renders) getNodeRevisionRef(node.id).value++ - } else { - // Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas - pricingTick.value++ } + pricingTick.value++ }) inflight.set(node, { sig, promise }) diff --git a/src/composables/node/usePriceBadge.ts b/src/composables/node/usePriceBadge.ts index e0f6d6b3ca..6b5f1ca2b7 100644 --- a/src/composables/node/usePriceBadge.ts +++ b/src/composables/node/usePriceBadge.ts @@ -18,6 +18,15 @@ export function usePriceBadge() { } else { node.badges.push(...newBadges) } + const graph = node.graph + if (!graph) return + graph.trigger('node:property:changed', { + type: 'node:property:changed', + nodeId: node.id, + property: 'badges', + oldValue: node.badges, + newValue: node.badges + }) } function collectCreditsBadges( graph: LGraph, diff --git a/tools/devtools/nodes/inputs.py b/tools/devtools/nodes/inputs.py index 56baaf06e1..4a89d77076 100644 --- a/tools/devtools/nodes/inputs.py +++ b/tools/devtools/nodes/inputs.py @@ -2,6 +2,8 @@ from __future__ import annotations import time +from comfy_api.v0_0_2 import IO + class LongComboDropdown: @classmethod @@ -317,6 +319,30 @@ class NodeWithLegacyWidget: def node_with_legacy_widget(self): return () +class NodeWithPriceBadge(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="DevToolsNodeWithPriceBadge", + display_name="Node With Price Badge", + description="An API node with a price badge", + inputs=[IO.Combo.Input("price", options=["1x", "2x", "3x"])], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["price"]), + expr=""" + ( + $p := widgets.price; + {"type":"usd","usd": $contains($p, "2x") ? 2 : $contains($p, "3x") ? 3 : 1} + ) + """, + ), + ) + + @classmethod + async def execute(cls, price): + return IO.NodeOutput() + NODE_CLASS_MAPPINGS = { "DevToolsLongComboDropdown": LongComboDropdown, @@ -334,6 +360,7 @@ NODE_CLASS_MAPPINGS = { "DevToolsNodeWithValidation": NodeWithValidation, "DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput, "DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget, + "DevToolsNodeWithPriceBadge": NodeWithPriceBadge, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -352,6 +379,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DevToolsNodeWithValidation": "Node With Validation", "DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input", "DevToolsNodeWithLegacyWidget": "Node With Legacy Widget", + "DevToolsNodeWithPriceBadge": "Node With Price Badge", } __all__ = [