diff --git a/browser_tests/assets/vueNodes/linked-standard-widgets.json b/browser_tests/assets/vueNodes/linked-standard-widgets.json new file mode 100644 index 00000000000..a85a0299345 --- /dev/null +++ b/browser_tests/assets/vueNodes/linked-standard-widgets.json @@ -0,0 +1,134 @@ +{ + "id": "bc8526ee-e7ce-4b81-9a55-12a34d43cc3a", + "revision": 0, + "last_node_id": 2, + "last_link_id": 9, + "nodes": [ + { + "id": 1, + "type": "DevToolsLinkedStandardWidgetSource", + "pos": [-1600, 100], + "size": [360, 270], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { "name": "plain_text", "type": "STRING", "links": [1] }, + { "name": "integer", "type": "INT", "links": [2] }, + { "name": "number", "type": "FLOAT", "links": [3] }, + { "name": "slider", "type": "FLOAT", "links": [4] }, + { "name": "switch", "type": "BOOLEAN", "links": [5] }, + { "name": "labeled_toggle", "type": "BOOLEAN", "links": [6] }, + { "name": "select", "type": "COMBO", "links": [7] }, + { "name": "color", "type": "COLOR", "links": [8] }, + { "name": "textarea", "type": "STRING", "links": [9] } + ], + "properties": { + "Node name for S&R": "DevToolsLinkedStandardWidgetSource" + }, + "widgets_values": [] + }, + { + "id": 2, + "type": "DevToolsLinkedStandardWidgets", + "pos": [200, 100], + "size": [560, 620], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "plain_text", + "type": "STRING", + "widget": { "name": "plain_text" }, + "link": 1 + }, + { + "name": "integer", + "type": "INT", + "widget": { "name": "integer" }, + "link": 2 + }, + { + "name": "number", + "type": "FLOAT", + "widget": { "name": "number" }, + "link": 3 + }, + { + "name": "slider", + "type": "FLOAT", + "widget": { "name": "slider" }, + "link": 4 + }, + { + "name": "switch", + "type": "BOOLEAN", + "widget": { "name": "switch" }, + "link": 5 + }, + { + "name": "labeled_toggle", + "type": "BOOLEAN", + "widget": { "name": "labeled_toggle" }, + "link": 6 + }, + { + "name": "select", + "type": "COMBO", + "widget": { "name": "select" }, + "link": 7 + }, + { + "name": "color", + "type": "COLOR", + "widget": { "name": "color" }, + "link": 8 + }, + { + "name": "textarea", + "type": "STRING", + "widget": { "name": "textarea" }, + "link": 9 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "DevToolsLinkedStandardWidgets" + }, + "widgets_values": [ + "STALE PLAIN TEXT", + 314, + 9.875, + 0.73, + true, + false, + "STALE SELECT VALUE", + "#22c55d", + "STALE MULTILINE PROMPT" + ] + } + ], + "links": [ + [1, 1, 0, 2, 0, "STRING"], + [2, 1, 1, 2, 1, "INT"], + [3, 1, 2, 2, 2, "FLOAT"], + [4, 1, 3, 2, 3, "FLOAT"], + [5, 1, 4, 2, 4, "BOOLEAN"], + [6, 1, 5, 2, 5, "BOOLEAN"], + [7, 1, 6, 2, 6, "COMBO"], + [8, 1, 7, 2, 7, "COLOR"], + [9, 1, 8, 2, 8, "STRING"] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + }, + "frontendVersion": "1.28.6" + }, + "version": 0.4 +} diff --git a/browser_tests/assets/widgets/linked_multiline_string.json b/browser_tests/assets/widgets/linked_multiline_string.json new file mode 100644 index 00000000000..87573f64630 --- /dev/null +++ b/browser_tests/assets/widgets/linked_multiline_string.json @@ -0,0 +1,72 @@ +{ + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "CLIPTextEncode", + "pos": [440, 80], + "size": [520, 280], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": ["stale local prompt"] + }, + { + "id": 2, + "type": "PrimitiveString", + "pos": [40, 120], + "size": [315, 58], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [1] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": ["authoritative upstream prompt"] + } + ], + "links": [[1, 2, 0, 1, 1, "STRING"]], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index f6bddedea6e..49249dcd341 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -168,6 +168,8 @@ export const TestIds = { widgets: { container: 'node-widgets', widget: 'node-widget', + linkedContent: 'linked-widget-content', + linkedPlaceholder: 'linked-widget-placeholder', layoutFieldLabel: 'widget-layout-field-label', formDropdownMenu: 'form-dropdown-menu', decrement: 'decrement', diff --git a/browser_tests/fixtures/utils/promotedMissingModel.ts b/browser_tests/fixtures/utils/promotedMissingModel.ts index 1984e118701..9b13e1788ac 100644 --- a/browser_tests/fixtures/utils/promotedMissingModel.ts +++ b/browser_tests/fixtures/utils/promotedMissingModel.ts @@ -235,18 +235,23 @@ export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors( const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle) await expect(node).toBeVisible() - const staleCombo = node.getByRole('combobox', { - name: PROMOTED_MODEL_WIDGET_NAME, + const linkedStatus = node.getByRole('img', { + name: `${PROMOTED_MODEL_WIDGET_NAME}: Linked input`, exact: true }) await expect( - staleCombo, - `${step.nodeTitle} should expose the stale linked interior widget` - ).toBeDisabled() + linkedStatus, + `${step.nodeTitle} should expose the linked interior status` + ).toBeVisible() + const staleValue = node.getByText(staleModelName, { exact: true }) await expect( - staleCombo, - `${step.nodeTitle} should keep the stale interior value` - ).toContainText(staleModelName) + staleValue, + `${step.nodeTitle} should retain the stale interior value` + ).toBeAttached() + await expect( + staleValue, + `${step.nodeTitle} should hide the stale interior value` + ).toBeHidden() await expectNoMissingModelUi(comfyPage) } } diff --git a/browser_tests/tests/subgraph/subgraphNested.spec.ts b/browser_tests/tests/subgraph/subgraphNested.spec.ts index 7b30330e96a..ec894f134f0 100644 --- a/browser_tests/tests/subgraph/subgraphNested.spec.ts +++ b/browser_tests/tests/subgraph/subgraphNested.spec.ts @@ -64,13 +64,21 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => { await comfyExpect(innerNode).toBeVisible() const innerTextboxes = innerNode.getByRole('textbox') - await comfyExpect(innerTextboxes).toHaveCount(2) - const innerValues = await innerTextboxes.evaluateAll< - string[], - HTMLInputElement - >((boxes) => boxes.map((b) => b.value)) - comfyExpect(innerValues).toContain('11111111111') - comfyExpect(innerValues).toContain('22222222222') + await comfyExpect(innerTextboxes).toHaveCount(1) + await comfyExpect(innerTextboxes).toHaveValue('11111111111') + + await comfyExpect( + innerNode.getByRole('img', { + name: 'text_1: Linked input', + exact: true + }) + ).toBeVisible() + const linkedTextbox = innerNode.getByTestId( + TestIds.widgets.linkedContent + ) + await comfyExpect(linkedTextbox).toHaveCount(1) + await comfyExpect(linkedTextbox).toHaveValue('22222222222') + await comfyExpect(linkedTextbox).toBeHidden() }) } ) diff --git a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts index b7ebc8fea3d..d9dae3f6ecf 100644 --- a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts +++ b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts @@ -377,12 +377,18 @@ test.describe( .toEqual(expect.arrayContaining(['string_a', 'value'])) const subgraphNode = comfyPage.vueNodes.getNodeLocator('5') - const linkedTextarea = subgraphNode.getByRole('textbox', { - name: 'string_a', - exact: true - }) - await expect(linkedTextarea).toBeVisible() + await expect( + subgraphNode.getByRole('img', { + name: 'string_a: Linked input', + exact: true + }) + ).toBeVisible() + const linkedTextarea = subgraphNode.getByTestId( + TestIds.widgets.linkedContent + ) + await expect(linkedTextarea).toHaveCount(1) await expect(linkedTextarea).toBeDisabled() + await expect(linkedTextarea).toBeHidden() const allTextareas = subgraphNode.getByRole('textbox') await expect(allTextareas.first()).toBeVisible() diff --git a/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts b/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts index eb44157d899..e894afa4a53 100644 --- a/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts +++ b/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts @@ -152,10 +152,15 @@ test.describe( await openSubgraphById(comfyPage, '11') - const interiorTextareas = comfyPage.page - .locator('[data-node-id]') - .getByRole('textbox') - await expect(interiorTextareas).toHaveCount(2) + const interiorNodes = comfyPage.page.locator('[data-node-id]') + await expect(interiorNodes.locator('textarea')).toHaveCount(2) + await expect(interiorNodes.getByRole('textbox')).toHaveCount(0) + await expect( + interiorNodes.getByRole('img', { + name: 'text: Linked input', + exact: true + }) + ).toHaveCount(2) await comfyPage.subgraph.exitViaBreadcrumb() diff --git a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts index ebaae234d91..127bc57b92c 100644 --- a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts +++ b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts @@ -733,11 +733,16 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => { const outerNode = comfyPage.vueNodes.getNodeLocator('5') await expect(outerNode).toBeVisible() - const textarea = outerNode - .getByRole('textbox', { name: 'string_a' }) - .first() - await expect(textarea).toBeVisible() + await expect( + outerNode.getByRole('img', { + name: 'string_a: Linked input', + exact: true + }) + ).toBeVisible() + const textarea = outerNode.getByTestId(TestIds.widgets.linkedContent) + await expect(textarea).toHaveCount(1) await expect(textarea).toBeDisabled() + await expect(textarea).toBeHidden() }) test('No legacy-prefixed or disconnected widgets remain on the node', async ({ diff --git a/browser_tests/tests/subgraph/subgraphSlots.spec.ts b/browser_tests/tests/subgraph/subgraphSlots.spec.ts index a10b9cbfd17..a5bdb8de864 100644 --- a/browser_tests/tests/subgraph/subgraphSlots.spec.ts +++ b/browser_tests/tests/subgraph/subgraphSlots.spec.ts @@ -419,9 +419,12 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => { const subgraphNode = comfyPage.vueNodes.getNodeLocator('19') await expect(subgraphNode).toBeVisible() - const seedWidget = subgraphNode.getByLabel('seed', { exact: true }) - await expect(seedWidget).toBeVisible() - await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget) + const seedStatus = subgraphNode.getByRole('img', { + name: 'renamed_seed: Linked input', + exact: true + }) + await expect(seedStatus).toBeVisible() + await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedStatus) await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false) @@ -475,16 +478,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => { ) .toBe(RENAMED_LABEL) - const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', { + const seedStatusAfter = subgraphNodeAfter.getByRole('img', { + name: `${RENAMED_LABEL}: Linked input`, exact: true }) - await expect(seedWidgetAfter).toBeVisible() + await expect(seedStatusAfter).toBeVisible() await expect( subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true }) ).toBeVisible() await SubgraphHelper.expectWidgetBelowHeader( subgraphNodeAfter, - seedWidgetAfter + seedStatusAfter ) }) }) diff --git a/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts index 6452587bd71..88a7a7e1d9e 100644 --- a/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts @@ -87,7 +87,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { await expect(widgets).toHaveCount(2) }) - test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({ + test('should show linked status for connected advanced widgets when advanced inputs are hidden', async ({ comfyPage }) => { const node = getNode(comfyPage) @@ -124,7 +124,10 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { await node.getByText(HIDE_ADVANCED_INPUTS).click() - await expect(maxShiftWidget).toBeVisible() + await expect( + node.getByRole('img', { name: 'max_shift: Linked input' }) + ).toBeVisible() + await expect(maxShiftWidget).toBeHidden() await expect(baseShiftWidget).toBeHidden() }) diff --git a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts index 985d9f17d05..ac618bf12ce 100644 --- a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts @@ -2,41 +2,58 @@ import { comfyExpect as expect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => { - test('should be disabled and not allow changing value when link connected to slot', async ({ + test('should hide a linked value and restore the control after disconnect', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('vueNodes/linked-int-widget') - const seedWidget = comfyPage.vueNodes - .getWidgetByName('KSampler', 'seed') - .first() - const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget) - const initialValue = Number(await controls.input.inputValue()) - - // Verify widget is disabled when linked - await expect(controls.incrementButton).toBeDisabled() - await expect(controls.decrementButton).toBeDisabled() - await expect(controls.input).toHaveValue(initialValue.toString()) + const samplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first() + const placeholder = samplerNode.getByTestId( + TestIds.widgets.linkedPlaceholder + ) + const linkedContent = samplerNode.getByTestId(TestIds.widgets.linkedContent) + const hiddenInput = linkedContent.locator('input[role="spinbutton"]') + const nodeBounds = await samplerNode.boundingBox() + const [samplerNodeRef] = + await comfyPage.nodeOps.getNodeRefsByType('KSampler') + if (!samplerNodeRef || !nodeBounds) { + throw new Error('Linked KSampler node did not render') + } + const seedWidgetRef = await samplerNodeRef.getWidgetByName('seed') + const initialValue = Number(await seedWidgetRef.getValue()) - await expect(seedWidget).toBeVisible() + await expect(placeholder).toHaveAccessibleName('seed: Linked input') + await expect(linkedContent).toHaveAttribute('inert', '') + await expect(hiddenInput).toBeDisabled() + await expect( + samplerNode.getByRole('spinbutton', { name: 'seed' }) + ).toHaveCount(0) + await placeholder.click() + await expect.poll(() => seedWidgetRef.getValue()).toBe(initialValue) - // Delete the node that is linked to the slot (freeing up the widget) - // Click on the header to select the node (clicking center may land on - // the widget area where pointerdown.stop prevents node selection) await comfyPage.vueNodes .getNodeByTitle('Int') .locator('.lg-node-header') .click() await comfyPage.vueNodes.deleteSelected() - // Test widget works when unlinked + await expect(placeholder).toHaveCount(0) + const seedWidget = comfyPage.vueNodes + .getWidgetByName('KSampler', 'seed') + .first() + const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget) + await expect(controls.input).toHaveValue(initialValue.toString()) await controls.incrementButton.click() await expect(controls.input).toHaveValue((initialValue + 1).toString()) await controls.decrementButton.click() await expect(controls.input).toHaveValue(initialValue.toString()) + await expect + .poll(async () => (await samplerNode.boundingBox())?.height) + .toBeCloseTo(nodeBounds.height, 0) }) test('displays control widgets with default state', async ({ comfyPage }) => { diff --git a/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts new file mode 100644 index 00000000000..1534ec6ca1e --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts @@ -0,0 +1,175 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' + +const TARGET_NODE_TYPE = 'DevToolsLinkedStandardWidgets' +const TARGET_NODE_TITLE = 'Linked Standard Widgets' +const WIDGET_NAMES = [ + 'plain_text', + 'integer', + 'number', + 'slider', + 'switch', + 'labeled_toggle', + 'select', + 'color', + 'textarea' +] as const + +test.describe( + 'Linked standard Vue widgets', + { tag: ['@vue-nodes', '@widget', '@node'] }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.page.setViewportSize({ width: 1280, height: 900 }) + await comfyPage.workflow.loadWorkflow('vueNodes/linked-standard-widgets') + }) + + test( + 'renders each linked control surface', + { tag: '@screenshot' }, + async ({ comfyPage }) => { + const [targetNodeRef] = + await comfyPage.nodeOps.getNodeRefsByType(TARGET_NODE_TYPE) + if (!targetNodeRef) + throw new Error('Target DevTools node was not loaded') + await targetNodeRef.centerOnNode() + + const targetNode = comfyPage.vueNodes + .getNodeByTitle(TARGET_NODE_TITLE) + .first() + await expect(targetNode).toBeVisible() + + const statuses = targetNode.getByTestId( + TestIds.widgets.linkedPlaceholder + ) + await expect(statuses).toHaveCount(WIDGET_NAMES.length) + + for (const name of WIDGET_NAMES) { + await expect( + targetNode.getByRole('img', { + name: `${name}: Linked input` + }) + ).toBeVisible() + } + + await comfyPage.nextFrame() + await comfyPage.nextFrame() + await expect(targetNode).toHaveScreenshot('linked-standard-widgets.png') + } + ) + + test('keeps linked controls inaccessible to focus', async ({ + comfyPage + }) => { + const targetNode = comfyPage.vueNodes + .getNodeByTitle(TARGET_NODE_TITLE) + .first() + await expect(targetNode).toBeVisible() + + const linkedContent = targetNode.getByTestId( + TestIds.widgets.linkedContent + ) + await expect(linkedContent).toHaveCount(WIDGET_NAMES.length) + + for (let index = 0; index < WIDGET_NAMES.length; index++) { + await expect(linkedContent.nth(index)).toHaveAttribute('inert', '') + await expect(linkedContent.nth(index)).toHaveAttribute( + 'aria-hidden', + 'true' + ) + } + + const interactive = targetNode.locator( + `[data-testid="${TestIds.widgets.linkedContent}"]:is(input, textarea, button, [role="slider"]), ` + + `[data-testid="${TestIds.widgets.linkedContent}"] :is(input, textarea, button, [role="slider"])` + ) + const interactiveCount = await interactive.count() + expect(interactiveCount).toBeGreaterThan(WIDGET_NAMES.length) + + const controlStates = await interactive.evaluateAll((elements) => + elements.map((element) => { + const disabled = + element instanceof HTMLButtonElement || + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ? element.disabled + : element.getAttribute('aria-disabled') === 'true' || + element.hasAttribute('data-disabled') + + if (element instanceof HTMLElement) element.focus() + + return { + disabled, + focused: document.activeElement === element + } + }) + ) + expect(controlStates.every(({ disabled }) => disabled)).toBe(true) + expect(controlStates.every(({ focused }) => !focused)).toBe(true) + + for (let index = 0; index < interactiveCount; index++) { + await expect(interactive.nth(index)).toBeHidden() + } + }) + + test('ignores pointer and keyboard input on linked controls', async ({ + comfyPage + }) => { + const [targetNodeRef] = + await comfyPage.nodeOps.getNodeRefsByType(TARGET_NODE_TYPE) + if (!targetNodeRef) throw new Error('Target DevTools node was not loaded') + + const targetNode = comfyPage.vueNodes + .getNodeByTitle(TARGET_NODE_TITLE) + .first() + await expect(targetNode).toBeVisible() + + const focusedLinkedContent = targetNode.locator( + `[data-testid="${TestIds.widgets.linkedContent}"]:focus-within` + ) + + for (const text of [ + 'STALE SELECT VALUE', + 'STALE ON', + 'STALE OFF', + '#22c55d' + ]) { + const staleValue = targetNode.getByText(text, { exact: true }) + await expect(staleValue).toBeAttached() + await expect(staleValue).toBeHidden() + } + + const widgetRefs = await Promise.all( + WIDGET_NAMES.map((name) => targetNodeRef.getWidgetByName(name)) + ) + const valuesBefore = await Promise.all( + widgetRefs.map((widget) => widget.getValue()) + ) + + await targetNode + .getByRole('img', { name: 'switch: Linked input' }) + .click() + await comfyPage.page.keyboard.press('Space') + + await targetNode + .getByRole('img', { name: 'select: Linked input' }) + .click() + await comfyPage.page.keyboard.press('ArrowUp') + await comfyPage.page.keyboard.press('Enter') + + await targetNode + .getByRole('img', { name: 'textarea: Linked input' }) + .click() + await comfyPage.page.keyboard.type('ignored input') + await expect(focusedLinkedContent).toHaveCount(0) + + await comfyPage.nextFrame() + await expect + .poll(() => Promise.all(widgetRefs.map((widget) => widget.getValue()))) + .toStrictEqual(valuesBefore) + }) + } +) diff --git a/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts-snapshots/linked-standard-widgets-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts-snapshots/linked-standard-widgets-chromium-linux.png new file mode 100644 index 00000000000..3489e0af2f6 Binary files /dev/null and b/browser_tests/tests/vueNodes/widgets/linked/linkedStandardWidgets.spec.ts-snapshots/linked-standard-widgets-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png index 2eb6df89275..f460a7567ab 100644 Binary files a/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png and b/browser_tests/tests/vueNodes/widgets/text/lockedWidgetHoverColor.spec.ts-snapshots/locked-widget-hover-color-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts index 61b9c9131e9..578313c5b1f 100644 --- a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts @@ -3,6 +3,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => { test.afterEach(async ({ comfyPage }) => { @@ -46,6 +47,56 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => { await expect(textarea).toHaveValue('Keep me around') }) + test('hides a linked core prompt without changing node geometry', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('widgets/linked_multiline_string') + + const clipNode = getFirstClipNode(comfyPage) + const placeholder = clipNode.getByTestId(TestIds.widgets.linkedPlaceholder) + const linkedContent = clipNode.getByTestId(TestIds.widgets.linkedContent) + const hiddenTextarea = clipNode.locator('textarea') + const nodeBounds = await clipNode.boundingBox() + + await expect(placeholder).toHaveAttribute( + 'data-linked-display', + 'expanding' + ) + await expect(placeholder).toHaveAccessibleName('text: Linked input') + await expect(linkedContent).toHaveAttribute('inert', '') + await expect(linkedContent).toHaveAttribute('aria-hidden', 'true') + await expect(hiddenTextarea).toBeHidden() + await expect(hiddenTextarea).toBeDisabled() + await expect(clipNode.getByRole('textbox', { name: 'text' })).toHaveCount(0) + await expect(hiddenTextarea).toHaveValue('stale local prompt') + + await hiddenTextarea.evaluate((element) => element.focus()) + await expect + .poll(() => + hiddenTextarea.evaluate((element) => document.activeElement === element) + ) + .toBe(false) + + const [clipNodeRef] = + await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + if (!clipNodeRef || !nodeBounds) { + throw new Error('Linked CLIPTextEncode node did not render') + } + const textInput = await clipNodeRef.getInput(1) + await textInput.removeLinks() + await comfyPage.nextFrame() + + await expect(placeholder).toHaveCount(0) + const restoredTextarea = getFirstMultilineStringWidget(comfyPage) + await expect(restoredTextarea).toBeVisible() + await expect(restoredTextarea).toHaveValue('stale local prompt') + await restoredTextarea.fill('restored local prompt') + await expect(restoredTextarea).toHaveValue('restored local prompt') + await expect + .poll(async () => (await clipNode.boundingBox())?.height) + .toBeCloseTo(nodeBounds.height, 0) + }) + test('should use native context menu when focused', async ({ comfyPage }) => { const textarea = getFirstMultilineStringWidget(comfyPage) const vueContextMenu = comfyPage.page.locator('.p-contextmenu') diff --git a/src/components/rightSidePanel/parameters/WidgetItem.test.ts b/src/components/rightSidePanel/parameters/WidgetItem.test.ts index 10fc410e265..c1a3d276875 100644 --- a/src/components/rightSidePanel/parameters/WidgetItem.test.ts +++ b/src/components/rightSidePanel/parameters/WidgetItem.test.ts @@ -1,27 +1,51 @@ -import { render } from '@testing-library/vue' import { fromAny } from '@total-typescript/shoehorn' -import { describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type * as WidgetRegistry from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry' import { useWidgetValueStore } from '@/stores/widgetValueStore' +import { toLinkId } from '@/types/linkId' +import { toNodeId } from '@/types/nodeId' import { widgetId } from '@/types/widgetId' + import WidgetItem from './WidgetItem.vue' -import { toNodeId } from '@/types/nodeId' -const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({ +const { + mockFromLGraphNode, + mockGetInputSpecForWidget, + mockIsAssetAPIEnabled, + mockShouldUseAssetBrowser, + StubWidgetComponent +} = vi.hoisted(() => ({ + mockFromLGraphNode: vi.fn<() => { isCoreNode: boolean; name: string } | null>( + () => null + ), mockGetInputSpecForWidget: vi.fn(), + mockIsAssetAPIEnabled: vi.fn(() => false), + mockShouldUseAssetBrowser: vi.fn(() => false), StubWidgetComponent: { name: 'StubWidget', props: ['widget', 'modelValue', 'nodeId', 'nodeType'], template: - '
' + '
' + } +})) + +vi.mock('@/platform/assets/services/assetService', () => ({ + assetService: { + isAssetAPIEnabled: mockIsAssetAPIEnabled, + shouldUseAssetBrowser: mockShouldUseAssetBrowser } })) vi.mock('@/stores/nodeDefStore', () => ({ useNodeDefStore: () => ({ + fromLGraphNode: mockFromLGraphNode, getInputSpecForWidget: mockGetInputSpecForWidget }) })) @@ -45,10 +69,17 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({ vi.mock( '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry', - () => ({ - getComponent: () => StubWidgetComponent, - shouldExpand: () => false - }) + async (importOriginal) => { + const original = await importOriginal() + const { default: WidgetInputText } = + await import('@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue') + return { + ...original, + getComponent: (type: string) => + type === 'text' ? WidgetInputText : StubWidgetComponent, + shouldExpand: () => false + } + } ) vi.mock( @@ -70,7 +101,9 @@ const i18n = createI18n({ } }) -function createMockNode(overrides: Partial = {}): LGraphNode { +function createMockNode( + overrides: Partial> = {} +): LGraphNode { return fromAny({ id: 1, type: 'TestNode', @@ -100,7 +133,7 @@ function renderWidgetItem( return render(WidgetItem, { props: { widget, node }, global: { - plugins: [i18n], + plugins: [i18n, PrimeVue], stubs: { EditableText: { template: '' }, WidgetActions: { template: '' } @@ -115,6 +148,7 @@ function getStubWidget(container: Element) { if (!el) throw new Error('stub-widget not found') return { options: JSON.parse(el.getAttribute('data-widget-options') ?? 'null'), + linkedDisplay: el.getAttribute('data-linked-display'), type: el.getAttribute('data-widget-type'), name: el.getAttribute('data-widget-name'), value: el.getAttribute('data-widget-value') @@ -122,6 +156,13 @@ function getStubWidget(container: Element) { } describe('WidgetItem', () => { + beforeEach(() => { + mockIsAssetAPIEnabled.mockReturnValue(false) + mockShouldUseAssetBrowser.mockReturnValue(false) + mockFromLGraphNode.mockReturnValue(null) + mockGetInputSpecForWidget.mockReset() + }) + describe('widget state rendering', () => { it('passes options from a regular widget to the widget component', () => { const widget = createMockWidget({ @@ -197,5 +238,143 @@ describe('WidgetItem', () => { expect(stub.value).toBe('model_a.safetensors') }) + + it('restores a linked text control in Parameters after disconnect', async () => { + const widget = createMockWidget({ + name: 'prompt', + type: 'text', + value: 'STALE PARAMETER TEXT' + }) + const promptInput = { + name: 'prompt', + type: 'STRING', + link: toLinkId(1), + boundingRect: [0, 0, 0, 0], + widget: { name: 'prompt' } + } + const node = createMockNode({ + inputs: [promptInput] + }) + + const view = renderWidgetItem(widget, node) + + const content = screen.getByTestId('linked-widget-content') + const input = screen.getByRole('textbox', { hidden: true }) + expect(content).toHaveAttribute('inert') + expect(content).toHaveAttribute('aria-hidden', 'true') + expect(input).toBeDisabled() + expect(screen.queryByRole('textbox')).toBeNull() + expect( + screen.getByRole('img', { name: 'prompt: Linked input' }) + ).toBeVisible() + + await view.rerender({ + widget, + node: createMockNode({ + inputs: [{ ...promptInput, link: null }] + }) + }) + + expect(screen.queryByRole('img')).toBeNull() + const restoredInput = screen.getByRole('textbox', { name: 'prompt' }) + expect(restoredInput).toBeVisible() + expect(restoredInput).toBeEnabled() + expect(restoredInput).toHaveValue('STALE PARAMETER TEXT') + + const user = userEvent.setup() + await user.clear(restoredInput) + await user.type(restoredInput, 'restored parameter text') + + expect(view.emitted()['update:widgetValue']).toContainEqual([ + 'restored parameter text' + ]) + }) + + it('uses the bounded linked resolver for ordinary and upload combos', () => { + const widget = createMockWidget({ name: 'option', type: 'COMBO' }) + const node = createMockNode({ + inputs: [ + { + name: 'option', + type: 'COMBO', + link: toLinkId(1), + boundingRect: [0, 0, 0, 0], + widget: { name: 'option' } + } + ] + }) + const { container, unmount } = renderWidgetItem(widget, node) + + expect(getStubWidget(container).linkedDisplay).toBe('control') + unmount() + + mockGetInputSpecForWidget.mockReturnValue({ + type: 'COMBO', + name: 'option', + image_upload: true + }) + const upload = renderWidgetItem(widget, node) + + expect(getStubWidget(upload.container).linkedDisplay).toBeNull() + expect(getStubWidget(upload.container).options.disabled).toBe(true) + }) + + it.for(['LoadImage', 'LoadImageMask', 'LoadImageOutput'])( + 'uses the linked presentation for the exact core %s selector', + (nodeType) => { + mockGetInputSpecForWidget.mockReturnValue({ + type: 'COMBO', + name: 'image', + image_upload: true + }) + mockFromLGraphNode.mockReturnValue({ + name: nodeType, + isCoreNode: true + }) + const widget = createMockWidget({ name: 'image', type: 'asset' }) + const node = createMockNode({ + type: nodeType, + inputs: [ + { + name: 'image', + type: 'COMBO', + link: toLinkId(1), + boundingRect: [0, 0, 0, 0], + widget: { name: 'image' } + } + ] + }) + + const { container } = renderWidgetItem(widget, node) + const stub = getStubWidget(container) + + expect(stub.linkedDisplay).toBe('control') + expect(stub.options.disabled).toBe(true) + } + ) + + it('does not add linked presentation to a special widget', () => { + const widget = createMockWidget({ + name: 'gradient', + type: 'gradientslider' + }) + const node = createMockNode({ + inputs: [ + { + name: 'gradient', + type: 'FLOAT', + link: toLinkId(1), + boundingRect: [0, 0, 0, 0], + widget: { name: 'gradient' } + } + ] + }) + const { container } = renderWidgetItem(widget, node) + const stub = getStubWidget(container) + + expect(stub.linkedDisplay).toBeNull() + expect(stub.options.disabled).toBe(true) + expect(screen.queryByTestId('linked-widget-placeholder')).toBeNull() + }) }) }) diff --git a/src/components/rightSidePanel/parameters/WidgetItem.vue b/src/components/rightSidePanel/parameters/WidgetItem.vue index 643edb4a858..63a5dde944b 100644 --- a/src/components/rightSidePanel/parameters/WidgetItem.vue +++ b/src/components/rightSidePanel/parameters/WidgetItem.vue @@ -8,12 +8,14 @@ import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { assetService } from '@/platform/assets/services/assetService' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue' import { getComponent, shouldExpand } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry' +import { resolveLinkedWidgetDisplay } from '@/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay' import { useNodeDefStore } from '@/stores/nodeDefStore' import { stripGraphPrefix, @@ -88,14 +90,27 @@ const simplifiedWidget = computed((): SimplifiedWidget => { const widgetType = widgetState?.type ?? widget.type const baseOptions = widgetState?.options ?? widget.options + const spec = nodeDefStore.getInputSpecForWidget(node, widgetName) + const nodeDef = nodeDefStore.fromLGraphNode(node) + const linkedDisplay = resolveLinkedWidgetDisplay( + { name: widgetName, type: widgetType, spec }, + baseOptions, + { + assetApiEnabled: assetService.isAssetAPIEnabled(), + coreNodeType: nodeDef?.isCoreNode ? nodeDef.name : undefined, + linked: isLinked.value, + useAssetBrowser: assetService.shouldUseAssetBrowser(node.type, widgetName) + } + ) const disabled = isLinked.value || !!widget.disabled || undefined return { name: widgetName, type: widgetType, value: widgetState?.value ?? widget.value, label: widgetState?.label ?? widget.label, + linkedDisplay, options: { ...baseOptions, disabled }, - spec: nodeDefStore.getInputSpecForWidget(node, widgetName), + spec, controlWidget: getControlWidget(widget) } }) diff --git a/src/components/ui/color-picker/ColorPicker.test.ts b/src/components/ui/color-picker/ColorPicker.test.ts new file mode 100644 index 00000000000..bec44b9c9e3 --- /dev/null +++ b/src/components/ui/color-picker/ColorPicker.test.ts @@ -0,0 +1,77 @@ +import { render, screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import ColorPicker from './ColorPicker.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + color: { + alpha: 'Alpha', + hex: 'Hex', + hue: 'Hue', + rgba: 'RGBA', + saturationBrightness: 'Color saturation and brightness' + } + } + } +}) + +describe('ColorPicker', () => { + it('closes an open popover when disabled', async () => { + const user = userEvent.setup() + const { rerender } = render(ColorPicker, { + global: { plugins: [i18n] }, + props: { modelValue: '#112233' } + }) + const trigger = screen.getByRole('button') + + await user.click(trigger) + expect( + await screen.findByRole('textbox', { name: 'Hex' }) + ).toBeInTheDocument() + + await rerender({ disabled: true, modelValue: '#112233' }) + + await waitFor(() => { + expect( + screen.queryByRole('textbox', { name: 'Hex' }) + ).not.toBeInTheDocument() + }) + expect(trigger).toBeDisabled() + }) + + it('does not reopen from a custom trigger when disabled', async () => { + const user = userEvent.setup() + const { rerender } = render(ColorPicker, { + global: { plugins: [i18n] }, + props: { modelValue: '#112233' }, + slots: { + trigger: '' + } + }) + const trigger = screen.getByRole('button', { name: 'Custom trigger' }) + + await user.click(trigger) + expect( + await screen.findByRole('textbox', { name: 'Hex' }) + ).toBeInTheDocument() + + await rerender({ disabled: true, modelValue: '#112233' }) + await waitFor(() => { + expect( + screen.queryByRole('textbox', { name: 'Hex' }) + ).not.toBeInTheDocument() + }) + + await user.click(trigger) + + expect( + screen.queryByRole('textbox', { name: 'Hex' }) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/ui/color-picker/ColorPicker.vue b/src/components/ui/color-picker/ColorPicker.vue index 8215e4e7a3f..057c3908785 100644 --- a/src/components/ui/color-picker/ColorPicker.vue +++ b/src/components/ui/color-picker/ColorPicker.vue @@ -14,7 +14,7 @@ import { cn } from '@comfyorg/tailwind-utils' import ColorPickerPanel from './ColorPickerPanel.vue' -const { alpha = true } = defineProps<{ +const { alpha = true, disabled = false } = defineProps<{ class?: string disabled?: boolean alpha?: boolean @@ -69,15 +69,26 @@ const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase()) const isOpen = ref(false) const contentStyle = useModalLiftedZIndex(isOpen) + +function updateOpen(open: boolean) { + isOpen.value = open && !disabled +} + +watch( + () => disabled, + (isDisabled) => { + if (isDisabled) isOpen.value = false + } +)
diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts index a36bc0fe375..8bf04f15293 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts @@ -16,6 +16,8 @@ import { widgetId } from '@/types/widgetId' import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil' import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager' +import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue' +import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue' import { computeProcessedWidgets, getWidgetIdentity, @@ -27,8 +29,21 @@ import { validationError } from '@/utils/__tests__/nodeErrorHelpers' const GRAPH_ID = 'graph-test' const NODE_ID = toNodeId(1) -const { executionIdToNodeLocatorId } = vi.hoisted(() => ({ - executionIdToNodeLocatorId: vi.fn() +const { + executionIdToNodeLocatorId, + isAssetAPIEnabled, + shouldUseAssetBrowser, + showNodeOptions +} = vi.hoisted(() => ({ + executionIdToNodeLocatorId: vi.fn(), + isAssetAPIEnabled: vi.fn(() => false), + shouldUseAssetBrowser: vi.fn(() => false), + showNodeOptions: vi.fn() +})) + +vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({ showNodeOptions })) +vi.mock('@/platform/assets/services/assetService', () => ({ + assetService: { isAssetAPIEnabled, shouldUseAssetBrowser } })) vi.mock('@/utils/graphTraversalUtil', async (importActual) => { @@ -70,11 +85,20 @@ const noopUi = { handleNodeRightClick: () => {} } -function processWidgets(widgets: SafeWidgetData[]) { +function processWidgets(widgets: SafeWidgetData[], ui: typeof noopUi = noopUi) { + return processNodeWidgets(widgets, 'TestNode', false, ui) +} + +function processNodeWidgets( + widgets: SafeWidgetData[], + nodeType: string, + isCoreNode: boolean, + ui: typeof noopUi = noopUi +) { return computeProcessedWidgets({ nodeData: { id: NODE_ID, - type: 'TestNode', + type: nodeType, widgets, title: 'Test', mode: 0, @@ -83,11 +107,12 @@ function processWidgets(widgets: SafeWidgetData[]) { inputs: [], outputs: [] }, + coreNodeType: isCoreNode ? nodeType : undefined, graphId: GRAPH_ID, showAdvanced: false, isGraphReady: false, rootGraph: null, - ui: noopUi + ui }) } @@ -737,6 +762,234 @@ describe('computeProcessedWidgets borderStyle', () => { }) }) +describe('computeProcessedWidgets linked presentation', () => { + const linkedSlot = { + index: 0, + linked: true, + originNodeId: toNodeId(2), + originOutputName: 'value', + type: 'STRING' + } + + beforeEach(() => { + isAssetAPIEnabled.mockReturnValue(false) + shouldUseAssetBrowser.mockReturnValue(false) + showNodeOptions.mockClear() + }) + + it('hides a linked standard value and disables its mounted control', () => { + const id = widgetId(GRAPH_ID, NODE_ID, 'prompt') + useWidgetValueStore().registerWidget(id, { + type: 'string', + value: 'stale local prompt', + options: {} + }) + const widget = createMockWidget({ + widgetId: id, + nodeId: NODE_ID, + name: 'prompt', + type: 'string', + slotMetadata: linkedSlot + }) + + const [linked] = processWidgets([widget]) + + expect(linked.linkedDisplay).toBe('control') + expect(linked.value).toBe('stale local prompt') + expect(linked.simplified.options?.disabled).toBe(true) + expect(linked.tooltipConfig.disabled).toBe(true) + expect(linked.simplified.linkedUpstream).toEqual({ + nodeId: toNodeId(2), + outputName: 'value' + }) + }) + + it('restores the standard widget state after disconnect', () => { + const id = widgetId(GRAPH_ID, NODE_ID, 'prompt') + useWidgetValueStore().registerWidget(id, { + type: 'string', + value: 'local prompt', + options: {} + }) + const widget = createMockWidget({ + widgetId: id, + nodeId: NODE_ID, + name: 'prompt', + type: 'string', + slotMetadata: linkedSlot + }) + + expect(processWidgets([widget])[0].linkedDisplay).toBe('control') + + const [unlinked] = processWidgets([ + { + ...widget, + slotMetadata: { ...linkedSlot, linked: false } + } + ]) + + expect(unlinked.linkedDisplay).toBeUndefined() + expect(unlinked.value).toBe('local prompt') + expect(unlinked.simplified.options?.disabled).toBeUndefined() + expect(unlinked.simplified.linkedUpstream).toBeUndefined() + expect(unlinked.tooltipConfig.disabled).toBeUndefined() + }) + + it('distinguishes unlabeled switches from labeled toggle groups', () => { + const switchWidget = createMockWidget({ + name: 'enabled', + type: 'boolean', + slotMetadata: { ...linkedSlot, type: 'BOOLEAN' } + }) + const toggleId = widgetId(GRAPH_ID, NODE_ID, 'mode') + useWidgetValueStore().registerWidget(toggleId, { + type: 'boolean', + value: false, + options: { off: 'Disabled', on: 'Enabled' } + }) + const toggleWidget = createMockWidget({ + widgetId: toggleId, + nodeId: NODE_ID, + name: 'mode', + type: 'toggle', + slotMetadata: { ...linkedSlot, type: 'BOOLEAN' } + }) + + expect(processWidgets([switchWidget])[0].linkedDisplay).toBe('switch') + expect(processWidgets([toggleWidget])[0].linkedDisplay).toBe('control') + }) + + it.for([ + ['String', false, WidgetLegacy], + ['CoMbO', true, WidgetDOM] + ] as const)( + 'keeps the unregistered mixed-case %s fallback visible', + ([type, isDOMWidget, component]) => { + const widget = createMockWidget({ + isDOMWidget, + type, + slotMetadata: linkedSlot + }) + + const [processed] = processWidgets([widget]) + + expect(processed.linkedDisplay).toBeUndefined() + expect(processed.simplified.options?.disabled).toBe(true) + expect(processed.vueComponent).toBe(component) + } + ) + + it.for([ + { name: 'asset alias', type: 'asset', spec: undefined }, + { + name: 'upload-media COMBO', + type: 'COMBO', + spec: { + type: 'COMBO', + name: 'image', + image_upload: true + } + } + ] as const)('keeps the linked $name rendered', ({ type, spec }) => { + const widget = createMockWidget({ + name: 'image', + type, + spec, + slotMetadata: linkedSlot + }) + + const [processed] = processWidgets([widget]) + + expect(processed.linkedDisplay).toBeUndefined() + expect(processed.simplified.options?.disabled).toBe(true) + }) + + it.for(['LoadImage', 'LoadImageMask', 'LoadImageOutput'])( + 'hides and disables a linked core %s selector', + (nodeType) => { + const widget = createMockWidget({ + name: 'image', + type: 'asset', + spec: { type: 'COMBO', name: 'image', image_upload: true }, + slotMetadata: linkedSlot + }) + + const [processed] = processNodeWidgets([widget], nodeType, true) + + expect(processed.linkedDisplay).toBe('control') + expect(processed.simplified.options?.disabled).toBe(true) + } + ) + + it('keeps an asset-browser combo rendered while hiding an ordinary combo', () => { + shouldUseAssetBrowser.mockReturnValue(true) + const widget = createMockWidget({ + name: 'ckpt_name', + type: 'COMBO', + slotMetadata: linkedSlot + }) + + expect(processWidgets([widget])[0].linkedDisplay).toBeUndefined() + + shouldUseAssetBrowser.mockReturnValue(false) + expect(processWidgets([widget])[0].linkedDisplay).toBe('control') + }) + + it('keeps upstream metadata for a linked relay widget', () => { + const widget = createMockWidget({ + type: 'range', + slotMetadata: linkedSlot + }) + + const [processed] = processWidgets([widget]) + + expect(processed.linkedDisplay).toBeUndefined() + expect(processed.simplified.options?.disabled).toBe(true) + expect(processed.simplified.linkedUpstream).toEqual({ + nodeId: toNodeId(2), + outputName: 'value' + }) + }) + + it('retains widget actions for a hidden linked widget', () => { + const handleNodeRightClick = vi.fn() + const event = fromAny({ + preventDefault: vi.fn(), + stopPropagation: vi.fn() + }) + const widget = createMockWidget({ + name: 'prompt', + nodeId: NODE_ID, + type: 'text', + slotMetadata: linkedSlot + }) + + processWidgets([widget], { + ...noopUi, + handleNodeRightClick + })[0].handleContextMenu(event) + + expect(handleNodeRightClick).toHaveBeenCalledWith(event, NODE_ID) + expect(showNodeOptions).toHaveBeenCalledWith(event, 'prompt', NODE_ID) + }) + + it('retains widget actions when the widget is unlinked', () => { + const event = fromAny({ + preventDefault: vi.fn(), + stopPropagation: vi.fn() + }) + const widget = createMockWidget({ + name: 'prompt', + nodeId: NODE_ID, + type: 'text' + }) + + processWidgets([widget])[0].handleContextMenu(event) + + expect(showNodeOptions).toHaveBeenCalledWith(event, 'prompt', NODE_ID) + }) +}) + describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => { it('calls widget.callback with the new value when widgetState exists', () => { const callback = vi.fn() diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts index 664280d3cb2..f16cc7e85cf 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts @@ -12,6 +12,7 @@ import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu' import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' import { useSettingStore } from '@/platform/settings/settingStore' +import { assetService } from '@/platform/assets/services/assetService' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' import type { NodeError } from '@/schemas/apiSchema' @@ -24,6 +25,7 @@ import { shouldExpand, shouldRenderAsVue } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry' +import { resolveLinkedWidgetDisplay } from '@/renderer/extensions/vueNodes/widgets/utils/linkedWidgetDisplay' import { nodeTypeValidForApp } from '@/stores/appModeStore' import { stripGraphPrefix, @@ -32,6 +34,7 @@ import { import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' import { useExecutionErrorStore } from '@/stores/executionErrorStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' import { createNodeExecutionId, createNodeLocatorId @@ -48,6 +51,7 @@ import { } from '@/utils/graphTraversalUtil' import type { LGraph } from '@/lib/litegraph/src/litegraph' import type { + LinkedWidgetDisplay, LinkedUpstreamInfo, SimplifiedWidget, WidgetValue @@ -66,6 +70,7 @@ interface ProcessedWidget { hasError: boolean hidden: boolean id?: string + linkedDisplay?: LinkedWidgetDisplay widgetId?: WidgetId name: string renderKey: string @@ -86,6 +91,7 @@ interface WidgetUiCallbacks { interface ComputeProcessedWidgetsOptions { nodeData: VueNodeData | undefined + coreNodeType?: string graphId: string | undefined showAdvanced: boolean isGraphReady: boolean @@ -222,6 +228,7 @@ export function isWidgetVisible( export function computeProcessedWidgets({ nodeData, + coreNodeType, graphId, showAdvanced, isGraphReady, @@ -330,6 +337,15 @@ export function computeProcessedWidgets({ const value = widgetState?.value as WidgetValue + const linkedDisplay = resolveLinkedWidgetDisplay(widget, mergedOptions, { + assetApiEnabled: assetService.isAssetAPIEnabled(), + coreNodeType, + linked: Boolean(widget.slotMetadata?.linked), + useAssetBrowser: assetService.shouldUseAssetBrowser( + nodeData.type, + widget.name + ) + }) const isDisabled = slotMetadata?.linked || widgetState?.disabled const widgetOptions = isDisabled ? { ...mergedOptions, disabled: true } @@ -362,6 +378,7 @@ export function computeProcessedWidgets({ callback: widget.callback, controlWidget: widget.controlWidget, label: widgetState?.label, + linkedDisplay, linkedUpstream, nodeLocatorId, options: widgetOptions, @@ -380,7 +397,9 @@ export function computeProcessedWidgets({ isTooltipValueType(widget.type) && String(value).length > 10 ? String(value) : undefined - const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip) + const tooltipConfig = linkedDisplay + ? { disabled: true } + : ui.getTooltipConfig(widget, valueTooltip) const handleContextMenu = (e: PointerEvent) => { e.preventDefault() e.stopPropagation() @@ -407,6 +426,7 @@ export function computeProcessedWidgets({ missingMediaStore ), hidden: mergedOptions.hidden ?? false, + linkedDisplay, widgetId: widget.widgetId, name: widget.name, renderKey, @@ -430,6 +450,7 @@ export function useProcessedWidgets( ) { const canvasStore = useCanvasStore() const settingStore = useSettingStore() + const nodeDefStore = useNodeDefStore() const { isSelectInputsMode } = useAppMode() const { handleNodeRightClick } = useNodeEventHandlers() @@ -460,16 +481,21 @@ export function useProcessedWidgets( ) }) - const processedWidgets = computed((): ProcessedWidget[] => - computeProcessedWidgets({ - nodeData: nodeDataGetter(), + const processedWidgets = computed((): ProcessedWidget[] => { + const nodeData = nodeDataGetter() + const nodeDef = nodeData + ? nodeDefStore.nodeDefsByName[nodeData.type] + : undefined + return computeProcessedWidgets({ + nodeData, + coreNodeType: nodeDef?.isCoreNode ? nodeDef.name : undefined, graphId: canvasStore.canvas?.graph?.rootGraph.id, showAdvanced: showAdvanced.value, isGraphReady: app.isGraphReady, rootGraph: app.isGraphReady ? app.rootGraph : null, ui }) - ) + }) const visibleWidgets = computed(() => processedWidgets.value.filter((w) => w.visible) diff --git a/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.test.ts b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.test.ts new file mode 100644 index 00000000000..056e5946ab7 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.test.ts @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick } from 'vue' + +import { i18n } from '@/i18n' + +import LinkedWidgetStatus from './LinkedWidgetStatus.vue' + +const originalLocale = i18n.global.locale.value +const originalFrenchMessages = i18n.global.getLocaleMessage('fr') + +afterEach(() => { + i18n.global.locale.value = originalLocale + i18n.global.setLocaleMessage('fr', originalFrenchMessages) +}) + +describe('LinkedWidgetStatus', () => { + it('exposes the linked state as a named image', () => { + render(LinkedWidgetStatus, { + props: { + display: 'control', + widget: { name: 'prompt', label: 'Prompt' } + } + }) + + expect( + screen.getByRole('img', { name: 'Prompt: Linked input' }) + ).toBeVisible() + }) + + it('allows pointer gestures to bubble to the canvas surface', async () => { + const onPointerdown = vi.fn() + const Harness = defineComponent({ + components: { LinkedWidgetStatus }, + setup: () => ({ onPointerdown }), + template: ` +
+ +
+ ` + }) + render(Harness) + const user = userEvent.setup() + + await user.pointer({ keys: '[MouseLeft]', target: screen.getByRole('img') }) + + expect(onPointerdown).toHaveBeenCalledTimes(1) + }) + + it('updates its accessible name when the locale changes', async () => { + render(LinkedWidgetStatus, { + props: { + display: 'control', + widget: { name: 'prompt' } + } + }) + i18n.global.mergeLocaleMessage('fr', { + widgets: { linkedInput: 'Entrée liée' } + }) + i18n.global.locale.value = 'fr' + + await nextTick() + + expect( + screen.getByRole('img', { name: 'prompt: Entrée liée' }) + ).toBeVisible() + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.vue b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.vue new file mode 100644 index 00000000000..abae29536a0 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/LinkedWidgetStatus.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue index 0abffea10d8..c4b371e46b3 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue @@ -33,7 +33,7 @@ const textMap: Record = { :aria-label="t('widgets.valueControl.' + mode)" :class=" cn( - 'flex shrink-0 cursor-pointer items-center justify-center border-none focus-visible:ring-2 focus-visible:ring-primary-background focus-visible:ring-offset-1 focus-visible:outline-none', + 'flex shrink-0 cursor-pointer items-center justify-center border-none focus-visible:ring-2 focus-visible:ring-primary-background focus-visible:ring-offset-1 focus-visible:outline-none disabled:cursor-default disabled:opacity-50', variant === 'badge' ? 'h-4.5 w-8 rounded-full' : 'size-6 rounded-sm', mode !== 'fixed' ? 'bg-primary-background/30 hover:bg-primary-background-hover/30' diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts index 6d05a06c6a8..984d227348c 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts @@ -19,12 +19,14 @@ const WidgetLayoutFieldStub = defineComponent({ const ColorPickerStub = defineComponent({ name: 'ColorPicker', props: { + disabled: { type: Boolean, default: false }, modelValue: { type: String, default: '' } }, emits: ['update:modelValue'], template: `` }) @@ -99,6 +101,13 @@ describe('WidgetColorPicker Value Binding', () => { expect(screen.getByTestId('color-picker-input')).toBeInTheDocument() }) + it('disables the color picker from widget options', () => { + const widget = createColorWidget('#ff0000', { disabled: true }) + renderComponent(widget, '#ff0000') + + expect(screen.getByTestId('color-picker-input')).toBeDisabled() + }) + it('renders layout field wrapper', () => { const widget = createColorWidget('#ff0000') renderComponent(widget, '#ff0000') diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue index d71ae699296..4e619e4d2b4 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue @@ -1,6 +1,10 @@ diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts index 52997914862..da023918092 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.test.ts @@ -168,6 +168,34 @@ describe('WidgetInputText Value Binding', () => { const textarea = container.querySelector('textarea') expect(textarea).not.toBeInTheDocument() }) + + it('restores the same text control after a link is removed', async () => { + const widget = createInputTextWidget('stale prompt', { + disabled: true + }) + widget.linkedDisplay = 'control' + const { rerender } = renderComponent(widget, 'stale prompt') + + const linkedInput = screen.getByRole('textbox', { hidden: true }) + expect(linkedInput).toBeDisabled() + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.getByTestId('linked-widget-content')).toHaveAttribute( + 'inert' + ) + expect( + screen.getByRole('img', { name: 'test_input: Linked input' }) + ).toBeVisible() + + await rerender({ + widget: createInputTextWidget('stale prompt'), + modelValue: 'stale prompt' + }) + + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('textbox')).toBeVisible() + expect(screen.getByRole('textbox')).toBeEnabled() + expect(screen.getByRole('textbox')).toHaveValue('stale prompt') + }) }) describe('Locked Field Hover Styling', () => { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue index 240d341d698..c4837e31a8c 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue @@ -67,6 +67,7 @@ const isReadOnly = computed(() => const layoutWidget = computed(() => ({ name: widget.name, label: widget.label, + linkedDisplay: widget.linkedDisplay, borderStyle: cn( widget.borderStyle, invalid && 'border border-destructive-background' diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue index 1fef32bdd89..22fe423fd59 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue @@ -28,15 +28,13 @@ import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/componen import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue' import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue' import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types' +import { resolveWidgetSelectMode } from '@/renderer/extensions/vueNodes/widgets/utils/widgetSelectMode' import type { ResultItemType } from '@/schemas/apiSchema' -import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { SimplifiedControlWidget, SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget' -import type { AssetKind } from '@/types/widgetTypes' type SelectControlWidget = SimplifiedControlWidget @@ -47,84 +45,24 @@ const props = defineProps<{ const modelValue = defineModel() -const comboSpec = computed(() => { - if (props.widget.spec && isComboInputSpec(props.widget.spec)) { - return props.widget.spec - } - return undefined -}) - -const specDescriptor = computed<{ - kind: AssetKind - allowUpload: boolean - folder: ResultItemType | undefined - subfolder: string | undefined -}>(() => { - const spec = comboSpec.value - if (!spec) { - return { - kind: 'unknown', - allowUpload: false, - folder: undefined, - subfolder: undefined - } - } - - const { - image_upload, - animated_image_upload, - video_upload, - image_folder, - audio_upload, - mesh_upload, - upload_subfolder - } = spec - - let kind: AssetKind = 'unknown' - if (video_upload) { - kind = 'video' - } else if (image_upload || animated_image_upload) { - kind = 'image' - } else if (audio_upload) { - kind = 'audio' - } else if (mesh_upload) { - kind = 'mesh' - } - - // TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec - - const allowUpload = - image_upload === true || - animated_image_upload === true || - video_upload === true || - audio_upload === true || - mesh_upload === true - - const folder = mesh_upload ? 'input' : image_folder - - return { - kind, - allowUpload, - folder, - subfolder: upload_subfolder - } -}) - -const isAssetMode = computed( - () => - assetService.shouldUseAssetBrowser(props.nodeType, props.widget.name) || - (assetService.isAssetAPIEnabled() && props.widget.type === 'asset') +const mode = computed(() => + resolveWidgetSelectMode(props.widget, { + assetApiEnabled: assetService.isAssetAPIEnabled(), + useAssetBrowser: assetService.shouldUseAssetBrowser( + props.nodeType, + props.widget.name + ) + }) ) -const assetKind = computed(() => specDescriptor.value.kind) -const isDropdownUIWidget = computed( - () => isAssetMode.value || assetKind.value !== 'unknown' -) -const allowUpload = computed(() => specDescriptor.value.allowUpload) +const assetKind = computed(() => mode.value.assetKind) +const isAssetMode = computed(() => mode.value.isAssetMode) +const isDropdownUIWidget = computed(() => mode.value.isDropdownUIWidget) +const allowUpload = computed(() => mode.value.allowUpload) const uploadFolder = computed(() => { - return specDescriptor.value.folder ?? 'input' + return mode.value.uploadFolder ?? 'input' }) -const uploadSubfolder = computed(() => specDescriptor.value.subfolder) +const uploadSubfolder = computed(() => mode.value.uploadSubfolder) const defaultLayoutMode = computed(() => { return isAssetMode.value ? 'list' : 'grid' }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts index df0714ba643..bf8495efd7d 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts @@ -426,6 +426,26 @@ describe('WidgetSelectDefault', () => { expect(screen.getByTestId('widget-select-default-trigger')).toBeDisabled() }) + it('closes an open overlay when widget options become disabled', async () => { + const { rerender, user } = renderComponent(createWidget(['a', 'b']), 'a') + + await openDropdown(user) + expect( + screen.getByTestId('widget-select-default-overlay') + ).toBeInTheDocument() + + await rerender({ + widget: createWidget(['a', 'b'], { disabled: true }) + }) + + await waitFor(() => { + expect( + screen.queryByTestId('widget-select-default-overlay') + ).not.toBeInTheDocument() + }) + expect(screen.getByTestId('widget-select-default-trigger')).toBeDisabled() + }) + it('uses getOptionLabel for trigger and option labels', async () => { const { user } = renderComponent( createWidget(['hash-a'], { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue index 5ba039de40b..ab7a2a51dbe 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue @@ -162,7 +162,7 @@ import { ComboboxRoot, ComboboxTrigger } from 'reka-ui' -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import type { CSSProperties } from 'vue' import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer' @@ -378,4 +378,8 @@ function handleOpenChange(open: boolean) { searchQuery.value = '' } } + +watch(disabled, (isDisabled) => { + if (isDisabled) handleOpenChange(false) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts index ad24302aeb9..b6bfbe24566 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts @@ -1,7 +1,8 @@ import { fromPartial } from '@total-typescript/shoehorn' import { createTestingPinia } from '@pinia/testing' -import { render, screen } from '@testing-library/vue' +import { render, screen, waitFor, within } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import PrimeVue from 'primevue/config' import { computed, nextTick, ref } from 'vue' import type { Ref } from 'vue' @@ -14,6 +15,12 @@ import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/compone import type { SimplifiedWidget } from '@/types/simplifiedWidget' import { createMockWidget } from './widgetTestUtils' +vi.mock('@/composables/useFeatureFlags', () => ({ + useFeatureFlags: () => ({ + flags: { modelUploadButtonEnabled: false } + }) +})) + const mockCheckState = vi.hoisted(() => vi.fn()) const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] })) @@ -38,7 +45,10 @@ vi.mock('@/scripts/api', () => ({ fetchApi: vi.fn(), apiURL: vi.fn((url: string) => url), addEventListener: vi.fn(), - removeEventListener: vi.fn() + removeEventListener: vi.fn(), + getServerFeature: vi.fn( + (_name: string, defaultValue: unknown) => defaultValue + ) } })) @@ -211,6 +221,52 @@ describe('WidgetSelectDropdown', () => { expect(screen.getByText('model_a.safetensors')).toBeDefined() }) + it('closes an open core selector when it becomes linked', async () => { + mockItemsRef.value = [ + { id: 'input-0', name: 'stale.png', label: 'stale.png' } + ] + mockSelectedSetRef.value = new Set(['input-0']) + const widget = createMockWidget({ + value: 'stale.png', + name: 'image', + type: 'asset', + options: { + values: ['stale.png'] + } + }) + const { rerender } = renderComponent(widget, 'stale.png') + const user = userEvent.setup() + + await user.click(screen.getByRole('button', { name: 'stale.png' })) + expect(await screen.findByTestId('form-dropdown-menu')).toBeVisible() + + const linkedWidget: SimplifiedWidget = { + ...widget, + linkedDisplay: 'control', + options: { ...widget.options, disabled: true } + } + await rerender({ widget: linkedWidget, modelValue: 'stale.png' }) + + await waitFor(() => + expect(screen.queryByTestId('form-dropdown-menu')).not.toBeInTheDocument() + ) + + const content = screen.getByTestId('linked-widget-content') + expect(content).toHaveAttribute('inert') + expect(content).toHaveAttribute('aria-hidden', 'true') + expect( + screen.queryByRole('button', { name: 'stale.png' }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('img', { name: 'image: Linked input' }) + ).toBeVisible() + for (const button of within(content).getAllByRole('button', { + hidden: true + })) { + expect(button).toBeDisabled() + } + }) + describe('composable wiring', () => { const items: FormDropdownItem[] = [ { id: 'input-0', name: 'cat.png', label: 'cat.png' }, diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index ade6869453b..3d01964e74b 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -200,6 +200,7 @@ async function updateFiles(files: File[]) { :show-base-model-filter :base-model-options :is-uploading + :close-on-disable="Boolean(widget.linkedDisplay)" v-bind="combinedProps" :loading-more="outputMediaAssets.isLoadingMore.value" class="w-full" diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts index 897c17cc3e3..1bdda93aabe 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts @@ -174,6 +174,49 @@ describe('WidgetTextarea Value Binding', () => { expect(textarea.value).toBe('initial content') }) + it('keeps an unlinked disabled textarea disabled and readonly', () => { + const widget = createTextareaWidget('readable content', { + disabled: true + }) + renderComponent(widget, 'readable content') + + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveAttribute('readonly') + expect(textarea).toBeDisabled() + }) + + it('restores the textarea after a link is removed', async () => { + const widget = createTextareaWidget('stale multiline', { + disabled: true + }) + widget.linkedDisplay = 'expanding' + const { rerender } = renderComponent(widget, 'stale multiline') + const textarea = screen.getByTestId('linked-widget-content') + + expect(textarea).toBeDisabled() + expect(textarea).toHaveAttribute('inert') + expect(textarea).toHaveAttribute('aria-hidden', 'true') + expect(screen.queryByRole('textbox')).toBeNull() + textarea.focus() + expect(textarea).not.toHaveFocus() + expect( + screen.getByRole('img', { + name: 'test_textarea: Linked input' + }) + ).toBeVisible() + expect(screen.queryByRole('button')).toBeNull() + + await rerender({ + widget: createTextareaWidget('stale multiline'), + modelValue: 'stale multiline' + }) + + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('textbox')).toBeVisible() + expect(screen.getByRole('textbox')).toBeEnabled() + expect(screen.getByRole('textbox')).toHaveValue('stale multiline') + }) + it('uses widget name as placeholder when no placeholder provided', () => { const widget = createTextareaWidget('test') const { container } = renderComponent(widget, 'test') diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue index d93dc889531..4f79f933538 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue @@ -3,7 +3,9 @@ :class=" cn( 'group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted', - !isReadOnly && 'hover:bg-component-node-widget-background-hovered', + !isReadOnly && + !isLinked && + 'hover:bg-component-node-widget-background-hovered', widget.borderStyle ) " @@ -25,12 +27,18 @@ WidgetInputBaseClass, 'size-full resize-none text-(length:--comfy-textarea-font-size) leading-normal', !hideLayoutField && 'pt-5', + isLinked && 'invisible', // Avoid overflow-auto when idle to prevent per-textarea compositing layers. 'overflow-hidden hover:overflow-auto focus:overflow-auto' ) " :placeholder :readonly="isReadOnly" + :disabled="isLinked || filteredProps.disabled" + :aria-hidden="isLinked ? 'true' : undefined" + :inert="isLinked ? true : undefined" + :tabindex="isLinked ? -1 : undefined" + :data-testid="isLinked ? 'linked-widget-content' : undefined" data-capture-wheel="true" @pointerdown.capture.stop="trackFocus" @pointermove.capture.stop @@ -38,7 +46,7 @@ @contextmenu.capture="handleContextMenu" /> +
@@ -68,6 +77,7 @@ import { } from '@/utils/widgetPropFilter' import { WidgetInputBaseClass } from './layout' +import LinkedWidgetStatus from './LinkedWidgetStatus.vue' const { widget, placeholder = '' } = defineProps<{ widget: SimplifiedWidget @@ -96,6 +106,7 @@ const id = useId() const isReadOnly = computed(() => Boolean(widget.options?.read_only || widget.options?.disabled) ) +const isLinked = computed(() => widget.linkedDisplay === 'expanding') function handleContextMenu(e: MouseEvent) { if (isNodeOptionsOpen() || isFocused.value) { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts index e0517fcc4f0..5956ff8e576 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts @@ -286,6 +286,20 @@ describe('WidgetToggleSwitch Value Binding', () => { } }) + it('disables ToggleGroup when disabled is set', () => { + const widget = createToggleWidget(false, { + on: 'yes', + off: 'no', + disabled: true + }) + mountComponent(widget, false) + + const buttons = screen.getAllByRole('button') + for (const button of buttons) { + expect(button).toBeDisabled() + } + }) + it('keeps an implicit switch unchanged when read_only is set', async () => { const widget = createToggleWidget(false, { read_only: true }) const onModelUpdate = vi.fn() @@ -314,6 +328,47 @@ describe('WidgetToggleSwitch Value Binding', () => { expect(control).not.toBeChecked() }) + it('exposes a linked status on the implicit switch surface', () => { + const widget = createToggleWidget(false) + widget.linkedDisplay = 'switch' + mountComponent(widget, false) + + const content = screen.getByTestId('linked-widget-content') + const control = screen.getByRole('switch', { hidden: true }) + expect(content).toHaveAttribute('inert') + expect(content).toHaveAttribute('aria-hidden', 'true') + expect(control).toBeDisabled() + expect(screen.queryByRole('switch')).toBeNull() + control.focus() + expect(control).not.toHaveFocus() + expect( + screen.getByRole('img', { + name: 'test_toggle: Linked input' + }) + ).toHaveAttribute('data-linked-display', 'switch') + }) + + it('keeps labeled linked toggles disabled behind one control status', () => { + const widget = createToggleWidget(false, { + on: 'enabled', + off: 'disabled' + }) + widget.linkedDisplay = 'control' + mountComponent(widget, false) + + const content = screen.getByTestId('linked-widget-content') + const controls = screen.getAllByRole('button', { hidden: true }) + expect(content).toHaveAttribute('inert') + expect(content).toHaveAttribute('aria-hidden', 'true') + expect(screen.queryByRole('button')).toBeNull() + for (const control of controls) expect(control).toBeDisabled() + expect( + screen.getByRole('img', { + name: 'test_toggle: Linked input' + }) + ).toHaveAttribute('data-linked-display', 'control') + }) + it('does not emit when clicking already-selected option', async () => { const widget = createToggleWidget(false, { on: 'yes', off: 'no' }) const onModelUpdate = vi.fn() diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue index 30a02c1e14d..0704f61e887 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue @@ -1,10 +1,21 @@ @@ -53,6 +72,7 @@ import { useHideLayoutField } from '@/types/widgetTypes' import { cn } from '@comfyorg/tailwind-utils' import { WidgetInputBaseClass } from './layout' +import LinkedWidgetStatus from './LinkedWidgetStatus.vue' import WidgetLayoutField from './layout/WidgetLayoutField.vue' const { widget } = defineProps<{ @@ -67,6 +87,7 @@ const { t } = useI18n() const hasLabels = computed(() => { return widget.options?.on != null || widget.options?.off != null }) +const isLinkedSwitch = computed(() => widget.linkedDisplay === 'switch') function handleOptionChange(value: string | undefined) { if (value) { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts index 4e66ae4ba4a..cb09ba7cf6c 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.test.ts @@ -1,7 +1,10 @@ -import { render, screen } from '@testing-library/vue' +import { createTestingPinia } from '@pinia/testing' +import { render, screen, waitFor } from '@testing-library/vue' import userEvent from '@testing-library/user-event' +import PrimeVue from 'primevue/config' import { describe, expect, it, vi } from 'vitest' -import { defineComponent, nextTick } from 'vue' +import { defineComponent, nextTick, ref } from 'vue' +import { createI18n } from 'vue-i18n' import type { SafeControlWidget, @@ -10,6 +13,7 @@ import type { } from '@/types/simplifiedWidget' import WidgetWithControl from './WidgetWithControl.vue' +import ValueControlPopover from './ValueControlPopover.vue' import { createMockWidget } from './widgetTestUtils' const PopoverStub = defineComponent({ @@ -24,9 +28,12 @@ const PopoverStub = defineComponent({ const ValueControlButtonStub = defineComponent({ name: 'ValueControlButton', - props: { mode: { type: String, default: '' } }, + props: { + disabled: { type: Boolean, default: false }, + mode: { type: String, default: '' } + }, template: - '' + '' }) const ValueControlPopoverStub = defineComponent({ @@ -43,6 +50,23 @@ const ValueControlPopoverStub = defineComponent({ ` }) +const PortaledPopoverStub = defineComponent({ + name: 'Popover', + components: { ValueControlPopover }, + setup() { + const open = ref(false) + return { open } + }, + template: ` + + + + + + + ` +}) + const RenderedComponent = defineComponent({ name: 'RenderedComponent', props: { @@ -58,6 +82,12 @@ const RenderedComponent = defineComponent({ ` }) +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} } +}) + const makeControlWidget = ( update: (value: WidgetValue) => void = () => {}, initial = 'randomize' @@ -87,6 +117,15 @@ const mount = (widget: SimplifiedControlWidget, modelValue = 0) => props: { widget, modelValue, component: RenderedComponent } }) +const mountWithPortal = (widget: SimplifiedControlWidget, modelValue = 0) => + render(WidgetWithControl, { + global: { + plugins: [createTestingPinia(), i18n, PrimeVue], + stubs: { Popover: PortaledPopoverStub } + }, + props: { widget, modelValue, component: RenderedComponent } + }) + describe('WidgetWithControl', () => { it('renders the passed component with widget and modelValue', () => { mount(makeControlWidget(), 42) @@ -119,4 +158,43 @@ describe('WidgetWithControl', () => { mount(makeControlWidget(update, 'randomize')) expect(update).not.toHaveBeenCalled() }) + + it('disables the value control with the wrapped widget', () => { + const widget = makeControlWidget() + widget.options = { disabled: true } + mount(widget) + + expect(screen.getByTestId('control-button')).toBeDisabled() + }) + + it('unmounts an open value-control portal when linked', async () => { + const update = vi.fn<(value: WidgetValue) => void>() + const widget = makeControlWidget(update, 'randomize') + const { rerender } = mountWithPortal(widget) + const user = userEvent.setup() + + await user.click(await screen.findByTestId('value-control')) + expect(await screen.findAllByRole('radio')).toHaveLength(4) + + const linkedWidget: SimplifiedControlWidget = { + ...widget, + linkedDisplay: 'control', + options: { ...widget.options, disabled: true } + } + update.mockClear() + await rerender({ + widget: linkedWidget, + modelValue: 0, + component: RenderedComponent + }) + + await waitFor(() => expect(screen.queryAllByRole('radio')).toHaveLength(0)) + const linkedButton = screen.getByTestId('value-control') + expect(linkedButton).toBeDisabled() + linkedButton.focus() + expect(linkedButton).not.toHaveFocus() + await user.click(linkedButton) + await user.keyboard('{Enter}') + expect(update).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue index 13e35d916da..37fd480165e 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue @@ -28,12 +28,22 @@ watch(controlModel, props.widget.controlWidget.update) diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts index 01115dc8fb0..87ff7e36af7 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts @@ -81,6 +81,9 @@ const MockPopover = { } interface MountDropdownOptions { + closeOnDisable?: boolean + disabled?: boolean + isOpen?: boolean searcher?: ( query: string, items: FormDropdownItem[], @@ -105,6 +108,9 @@ function mountDropdown( const result = render(FormDropdown, { props: { items, + closeOnDisable: options.closeOnDisable, + disabled: options.disabled, + ...(options.isOpen === undefined ? {} : { isOpen: options.isOpen }), multiple: options.multiple, selected: options.selected, searcher: options.searcher, @@ -457,6 +463,56 @@ describe('FormDropdown', () => { expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false) }) + it('closes when a linked display disables the dropdown', async () => { + const onUpdateIsOpen = vi.fn() + const items = [createItem('1', 'alpha')] + const { rerender, user } = mountDropdown(items, { + closeOnDisable: true, + onUpdateIsOpen + }) + await openDropdown(user) + + expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true) + + await rerender({ + items, + closeOnDisable: true, + disabled: true, + 'onUpdate:isOpen': onUpdateIsOpen + }) + + expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false) + }) + + it('closes when mounted disabled with closeOnDisable', () => { + const onUpdateIsOpen = vi.fn() + mountDropdown([createItem('1', 'alpha')], { + closeOnDisable: true, + disabled: true, + isOpen: true, + onUpdateIsOpen + }) + + expect(onUpdateIsOpen).toHaveBeenCalledWith(false) + }) + + it('stays open when disabled without closeOnDisable', async () => { + const onUpdateIsOpen = vi.fn() + const items = [createItem('1', 'alpha')] + const { rerender, user } = mountDropdown(items, { onUpdateIsOpen }) + await openDropdown(user) + + expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true) + + await rerender({ + items, + disabled: true, + 'onUpdate:isOpen': onUpdateIsOpen + }) + + expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true) + }) + it('stays open on a pointerdown inside the menu', async () => { const onUpdateIsOpen = vi.fn() const { user } = mountDropdown([createItem('1', 'alpha')], { diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue index 81136a03c7f..a65aa12a13b 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue @@ -7,7 +7,7 @@ import { } from '@vueuse/core' import Popover from 'primevue/popover' import type { ComponentPublicInstance } from 'vue' -import { computed, ref, useTemplateRef } from 'vue' +import { computed, ref, useTemplateRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useToastStore } from '@/platform/updates/common/toastStore' @@ -41,6 +41,7 @@ interface Props { uploadable?: boolean disabled?: boolean + closeOnDisable?: boolean accept?: string filterOptions?: FilterOption[] sortOptions?: SortOption[] @@ -69,6 +70,7 @@ const { multiple = false, uploadable = false, disabled = false, + closeOnDisable = false, accept, filterOptions = [], sortOptions = getDefaultSortOptions(), @@ -210,12 +212,20 @@ function focusTrigger() { const closeDropdown = ({ restoreFocus = false } = {}) => { if (popoverRef.value) { popoverRef.value.hide?.() - isOpen.value = false } + isOpen.value = false if (restoreFocus) focusTrigger() } +watch( + [() => disabled, () => closeOnDisable], + ([isDisabled, shouldClose]) => { + if (isDisabled && shouldClose) closeDropdown() + }, + { immediate: true } +) + /** * Dismiss on `pointerdown` rather than PrimeVue's default `click` (mouseup) so * the dropdown closes the instant an outside press lands, and a focused inner diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue index 66486215045..248134d9e11 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue @@ -78,6 +78,7 @@ defineExpose({ focus, showPicker }) > ' + } + }) + + const content = screen.getByTestId('linked-widget-content') + const control = screen.getByRole('button', { hidden: true }) + expect(content).toHaveAttribute('inert') + expect(content).toHaveAttribute('aria-hidden', 'true') + expect(control).toBeDisabled() + expect(screen.queryByRole('button')).toBeNull() + expect( + screen.getByRole('img', { name: 'prompt: Linked input' }) + ).toBeVisible() + + await rerender({ widget: { name: 'prompt' } }) + + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByRole('button', { name: 'stale prompt' })).toBeVisible() + }) }) // user-event models clicks/keyboard but not raw pointerdown/move/up. diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue index d7824b8e482..ab4e07160dc 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue @@ -5,13 +5,22 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget' import { useHideLayoutField } from '@/types/widgetTypes' import { cn } from '@comfyorg/tailwind-utils' -const { widget, rootClass } = defineProps<{ +import LinkedWidgetStatus from '../LinkedWidgetStatus.vue' + +const { + widget, + rootClass, + linkedStatusRounded = 'md', + showLinkedStatus = true +} = defineProps<{ widget: Pick< SimplifiedWidget, - 'name' | 'label' | 'borderStyle' + 'name' | 'label' | 'borderStyle' | 'linkedDisplay' > rootClass?: string noBorder?: boolean + linkedStatusRounded?: 'md' | 'lg' + showLinkedStatus?: boolean }>() const hideLayoutField = useHideLayoutField() @@ -21,6 +30,11 @@ const borderStyle = computed(() => widget.borderStyle ) ) +const linkedDisplay = computed(() => + showLinkedStatus && widget.linkedDisplay === 'control' + ? widget.linkedDisplay + : undefined +)