Merge main (as of 10-06-2025) into rh-test (#5965)

## Summary

Merges latest changes from `main` as of 10-06-2025.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770)
by [Unito](https://www.unito.io)

---------

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: JakeSchroeder <jake@axiom.co>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
Arjan Singh
2025-10-08 19:06:40 -07:00
committed by GitHub
parent 529a4de583
commit 5869b04e57
454 changed files with 32333 additions and 37002 deletions

View File

@@ -50,16 +50,6 @@ describe('WidgetButton Interactions', () => {
expect(mockCallback).toHaveBeenCalledTimes(1)
})
it('does not call callback when button is readonly', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget, true)
await clickButton(wrapper)
expect(mockCallback).not.toHaveBeenCalled()
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget({}, undefined)
const wrapper = mountComponent(widget)
@@ -75,7 +65,6 @@ describe('WidgetButton Interactions', () => {
const numClicks = 8
await clickButton(wrapper)
for (let i = 0; i < numClicks; i++) {
await clickButton(wrapper)
}
@@ -134,26 +123,6 @@ describe('WidgetButton Interactions', () => {
})
})
describe('Readonly Mode', () => {
it('disables button when readonly', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget, true)
// Test the actual DOM button element instead of the Vue component props
const buttonElement = wrapper.find('button')
expect(buttonElement.element.disabled).toBe(true)
})
it('enables button when not readonly', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget, false)
// Test the actual DOM button element instead of the Vue component props
const buttonElement = wrapper.find('button')
expect(buttonElement.element.disabled).toBe(false)
})
})
describe('Widget Options', () => {
it('handles button with text only', () => {
const widget = createMockWidget({ label: 'Click Me' })

View File

@@ -3,12 +3,7 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
<Button v-bind="filteredProps" size="small" @click="handleClick" />
</div>
</template>
@@ -25,7 +20,6 @@ import {
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
@@ -36,7 +30,7 @@ const filteredProps = computed(() =>
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
if (props.widget.callback) {
props.widget.callback()
}
}

View File

@@ -22,7 +22,6 @@ const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const chartType = computed(() => props.widget.options?.type ?? 'line')

View File

@@ -93,7 +93,8 @@ describe('WidgetColorPicker Value Binding', () => {
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes rgb() strings to #hex on emit', async () => {
it('normalizes rgb() strings to #hex on emit', async (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget('#000000')
const wrapper = mountComponent(widget, '#000000')
@@ -186,24 +187,6 @@ describe('WidgetColorPicker Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables color picker when readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', true)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(true)
})
it('enables color picker when not readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', false)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(false)
})
})
describe('Color Formats', () => {
it('handles valid hex colors', async () => {
const validHexColors = [

View File

@@ -9,7 +9,6 @@
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-8 h-4 !rounded-full overflow-hidden border-none"
:pt="{
preview: '!w-full !h-full !border-none'
@@ -48,7 +47,6 @@ type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
const props = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -209,23 +209,6 @@ describe('WidgetFileUpload File Handling', () => {
expect(editIcon.exists()).toBe(true)
expect(deleteIcon.exists()).toBe(true)
})
it('hides control buttons in readonly mode', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile], true)
const controlButtons = wrapper.find('.absolute.top-2.right-2')
expect(controlButtons.exists()).toBe(false)
})
})
describe('Audio File Display', () => {
@@ -427,80 +410,6 @@ describe('WidgetFileUpload File Handling', () => {
})
})
describe('Readonly Mode', () => {
it('disables browse button in readonly mode', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const browseButton = wrapper.find('button')
expect(browseButton.element.disabled).toBe(true)
})
it('disables file input in readonly mode', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const fileInput = wrapper.find('input[type="file"]')
const inputElement = fileInput.element
if (!(inputElement instanceof HTMLInputElement)) {
throw new Error('Expected HTMLInputElement')
}
expect(inputElement.disabled).toBe(true)
})
it('disables folder button for images in readonly mode', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile], true)
const buttons = wrapper.findAll('button')
const folderButton = buttons.find((button) =>
button.element.innerHTML.includes('pi-folder')
)
if (!folderButton) {
throw new Error('Folder button not found')
}
expect(folderButton.element.disabled).toBe(true)
})
it('does not handle file changes in readonly mode', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const file = createMockFile('test.jpg', 'image/jpeg')
const fileInput = wrapper.find('input[type="file"]')
Object.defineProperty(fileInput.element, 'files', {
value: [file],
writable: false
})
await fileInput.trigger('change')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
})
describe('Edge Cases', () => {
it('handles empty file selection gracefully', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {

View File

@@ -31,7 +31,6 @@
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
@@ -47,7 +46,6 @@
/>
<!-- Control buttons in top right on hover -->
<div
v-if="!readonly"
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<!-- Edit button -->
@@ -100,7 +98,6 @@
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
@@ -128,7 +125,7 @@
</div>
<!-- Control buttons -->
<div v-if="!readonly" class="flex gap-1">
<div class="flex gap-1">
<!-- Delete button -->
<button
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
@@ -148,7 +145,7 @@
:style="{ borderColor: '#262729' }"
>
<div
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-slate-300"
:style="{ borderColor: '#262729' }"
>
<div class="flex flex-col items-center gap-2 w-full py-4">
@@ -159,7 +156,6 @@
size="small"
severity="secondary"
class="text-xs"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
@@ -173,7 +169,6 @@
class="hidden"
:accept="widget.options?.accept"
:multiple="false"
:disabled="readonly"
@change="handleFileChange"
/>
</template>
@@ -187,14 +182,9 @@ import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
modelValue,
readonly = false
} = defineProps<{
const { widget, modelValue } = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -284,7 +274,7 @@ const triggerFileInput = () => {
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (!readonly && target.files && target.files.length > 0) {
if (target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]

View File

@@ -61,8 +61,7 @@ function createMockWidget(
function mountComponent(
widget: SimplifiedWidget<GalleryValue>,
modelValue: GalleryValue,
readonly = false
modelValue: GalleryValue
) {
return mount(WidgetGalleria, {
global: {
@@ -71,7 +70,6 @@ function mountComponent(
},
props: {
widget,
readonly,
modelValue
}
})
@@ -87,11 +85,10 @@ function createImageStrings(count: number): string[] {
// Factory function that takes images, creates widget internally, returns wrapper
function createGalleriaWrapper(
images: GalleryValue,
options: Partial<GalleriaProps> = {},
readonly = false
options: Partial<GalleriaProps> = {}
) {
const widget = createMockWidget(images, options)
return mountComponent(widget, images, readonly)
return mountComponent(widget, images)
}
describe('WidgetGalleria Image Display', () => {
@@ -249,25 +246,6 @@ describe('WidgetGalleria Image Display', () => {
})
})
describe('Readonly Mode', () => {
it('passes readonly state to galleria when readonly', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images, true)
// Galleria component should receive readonly state (though it may not support disabled)
expect(wrapper.props('readonly')).toBe(true)
})
it('passes readonly state to galleria when not readonly', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images, false)
expect(wrapper.props('readonly')).toBe(false)
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const images = createImageStrings(2)

View File

@@ -72,7 +72,6 @@ const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)

View File

@@ -41,7 +41,6 @@ export interface ImageCompareValue {
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
readonly?: boolean
}>()
const beforeImage = computed(() => {

View File

@@ -6,7 +6,6 @@ import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
@@ -21,7 +20,6 @@ const modelValue = defineModel<number>({ default: 0 })
"
v-model="modelValue"
:widget="widget"
:readonly="readonly"
v-bind="$attrs"
/>
</template>

View File

@@ -22,11 +22,7 @@ function createMockWidget(
}
}
function mountComponent(
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) {
function mountComponent(widget: SimplifiedWidget<number>, modelValue: number) {
return mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
@@ -34,8 +30,7 @@ function mountComponent(
},
props: {
widget,
modelValue,
readonly
modelValue
}
})
}
@@ -93,14 +88,6 @@ describe('WidgetInputNumberInput Component Rendering', () => {
expect(inputNumber.props('showButtons')).toBe(true)
})
it('disables input when readonly', () => {
const widget = createMockWidget(5, 'int', {}, undefined)
const wrapper = mountComponent(widget, 5, true)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
})
it('sets button layout to horizontal', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
@@ -244,7 +231,8 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(inputNumber.props('showButtons')).toBe(false)
})
it('shows tooltip for disabled buttons due to precision limits', () => {
it('shows tooltip for disabled buttons due to precision limits', (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
@@ -279,16 +267,9 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX - 1)).toBe(false)
})
it('maintains readonly behavior even for unsafe values', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER, true)
it('handles floating point values correctly', (context) => {
context.skip('needs diagnosis')
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
expect(inputNumber.props('showButtons')).toBe(false) // Still hidden due to unsafe value
})
it('handles floating point values correctly', () => {
const safeFloat = 1000.5
const widget = createMockWidget(safeFloat, 'float')
const wrapper = mountComponent(widget, safeFloat)
@@ -297,7 +278,9 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(inputNumber.props('showButtons')).toBe(true)
})
it('hides buttons for unsafe floating point values', () => {
it('hides buttons for unsafe floating point values', (context) => {
context.skip('needs diagnosis')
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
const widget = createMockWidget(unsafeFloat, 'float')
const wrapper = mountComponent(widget, unsafeFloat)
@@ -326,7 +309,8 @@ describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
})
it('handles NaN values gracefully', () => {
it('handles NaN values gracefully', (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget(NaN, 'int')
const wrapper = mountComponent(widget, NaN)

View File

@@ -16,7 +16,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -72,7 +71,6 @@ const buttonsDisabled = computed(() => {
// Tooltip message for disabled buttons
const buttonTooltip = computed(() => {
if (props.readonly) return null
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
}
@@ -89,7 +87,6 @@ const buttonTooltip = computed(() => {
:show-buttons="!buttonsDisabled"
button-layout="horizontal"
size="small"
:disabled="readonly"
:step="stepValue"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"

View File

@@ -86,7 +86,6 @@ describe('WidgetInputNumberSlider Value Binding', () => {
it('renders input field', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
console.log(wrapper.html())
expect(wrapper.find('input[inputmode="numeric"]').exists()).toBe(true)
})
@@ -98,17 +97,6 @@ describe('WidgetInputNumberSlider Value Binding', () => {
const input = getNumberInput(wrapper)
expect(input.value).toBe('42')
})
it('disables components in readonly mode', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5, true)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('disabled')).toBe(true)
const input = getNumberInput(wrapper)
expect(input.disabled).toBe(true)
})
})
describe('Widget Options', () => {

View File

@@ -8,7 +8,6 @@
<Slider
:model-value="[localValue]"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow text-xs"
:step="stepValue"
@update:model-value="updateLocalValue"
@@ -17,7 +16,6 @@
:key="timesEmptied"
:model-value="localValue"
v-bind="filteredProps"
:disabled="readonly"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
@@ -46,10 +44,9 @@ import {
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget, modelValue, readonly } = defineProps<{
const { widget, modelValue } = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -137,19 +137,6 @@ describe('WidgetInputText Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables input when readonly', () => {
const widget = createMockWidget('readonly test')
const wrapper = mountComponent(widget, 'readonly test', true)
const input = wrapper.find('input[type="text"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error('Input element not found or is not an HTMLInputElement')
}
expect(input.element.disabled).toBe(true)
})
})
describe('Component Rendering', () => {
it('always renders InputText component', () => {
const widget = createMockWidget('test value')

View File

@@ -3,7 +3,6 @@
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
size="small"
@update:model-value="onChange"
@@ -29,7 +28,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -81,7 +81,10 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(displayDiv.html()).toContain('<em>italic</em>')
})
it('starts in display mode by default', () => {
it('starts in display mode by default', (context) => {
context.skip(
'Something in the logic in these tests is definitely off. needs diagnosis'
)
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -89,19 +92,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('applies styling classes to display container', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.classes()).toContain('text-xs')
expect(displayDiv.classes()).toContain('min-h-[60px]')
expect(displayDiv.classes()).toContain('rounded-lg')
expect(displayDiv.classes()).toContain('px-4')
expect(displayDiv.classes()).toContain('py-2')
expect(displayDiv.classes()).toContain('overflow-y-auto')
})
it('handles empty markdown content', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
@@ -113,7 +103,8 @@ describe('WidgetMarkdown Dual Mode Display', () => {
})
describe('Edit Mode Toggle', () => {
it('switches to edit mode when clicked', async () => {
it('switches to edit mode when clicked', async (context) => {
context.skip('markdown editor not disappearing. needs diagnosis')
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -125,16 +116,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('does not switch to edit mode when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
await clickToEdit(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('does not switch to edit mode when already editing', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -148,7 +129,8 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('switches back to display mode on textarea blur', async () => {
it('switches back to display mode on textarea blur', async (context) => {
context.skip('textarea not disappearing. needs diagnosis')
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -174,7 +156,10 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(textarea.element.value).toBe('# Original Content')
})
it('applies styling and configuration to textarea', async () => {
it('applies styling and configuration to textarea', async (context) => {
context.skip(
'Props or styling are not as described in the test. needs diagnosis'
)
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -189,15 +174,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(textarea.classes()).toContain('w-full')
})
it('disables textarea when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
// Readonly should prevent entering edit mode
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('stops click and keydown event propagation in edit mode', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')

View File

@@ -15,7 +15,6 @@
v-show="isEditing"
ref="textareaRef"
v-model="localValue"
:disabled="readonly"
class="w-full min-h-[60px] absolute inset-0 resize-none"
:pt="{
root: {
@@ -23,6 +22,7 @@
onBlur: handleBlur
}
}"
data-capture-wheel="true"
@update:model-value="onChange"
@click.stop
@keydown.stop
@@ -44,7 +44,6 @@ import LODFallback from '../../components/LODFallback.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -69,7 +68,7 @@ const renderedHtml = computed(() => {
// Methods
const startEditing = async () => {
if (props.readonly || isEditing.value) return
if (isEditing.value) return
isEditing.value = true
await nextTick()

View File

@@ -176,33 +176,6 @@ describe('WidgetMultiSelect Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables multiselect when readonly', () => {
const widget = createMockWidget(['selected'], {
values: ['selected', 'other']
})
const wrapper = mountComponent(widget, ['selected'], true)
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('disabled')).toBe(true)
})
it('disables interaction but allows programmatic changes', async () => {
const widget = createMockWidget(['initial'], {
values: ['initial', 'other']
})
const wrapper = mountComponent(widget, ['initial'], true)
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// The MultiSelect should be disabled, preventing user interaction
expect(multiselect.props('disabled')).toBe(true)
// But programmatic changes (like from external updates) should still work
// This is the expected behavior - readonly prevents UI interaction, not programmatic updates
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const widget = createMockWidget([], {

View File

@@ -4,7 +4,6 @@
v-model="localValue"
:options="multiSelectOptions"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
display="chip"
@@ -33,7 +32,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<T[]>
modelValue: T[]
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,3 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
@@ -43,7 +44,7 @@ describe('WidgetSelect Value Binding', () => {
readonly
},
global: {
plugins: [PrimeVue],
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
@@ -113,16 +114,6 @@ describe('WidgetSelect Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables the select component when readonly', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1', true)
const select = wrapper.findComponent({ name: 'Select' })
expect(select.props('disabled')).toBe(true)
})
})
describe('Option Handling', () => {
it('handles empty options array', async () => {
const widget = createMockWidget('', { values: [] })
@@ -204,7 +195,8 @@ describe('WidgetSelect Value Binding', () => {
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
})
it('uses dropdown variant for audio uploads', () => {
it('uses dropdown variant for audio uploads', (context) => {
context.skip('allowUpload is not false, should it be? needs diagnosis')
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',

View File

@@ -31,7 +31,6 @@ import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -84,6 +83,7 @@ const specDescriptor = computed<{
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
video_upload === true ||
audio_upload === true
return {
kind,

View File

@@ -125,7 +125,9 @@ describe('WidgetSelectButton Button Selection', () => {
})
})
it('updates selection when modelValue changes', async () => {
it('updates selection when modelValue changes', async (context) => {
context.skip('Classes not updating, needs diagnosis')
const options = ['first', 'second', 'third']
const widget = createMockWidget('first', { values: options })
const wrapper = mountComponent(widget, 'first')
@@ -155,7 +157,8 @@ describe('WidgetSelectButton Button Selection', () => {
expect(emitted?.[0]).toEqual(['second'])
})
it('handles callback execution when provided', async () => {
it('handles callback execution when provided', async (context) => {
context.skip('Callback is not being called, needs diagnosis')
const mockCallback = vi.fn()
const options = ['option1', 'option2']
const widget = createMockWidget(
@@ -196,48 +199,6 @@ describe('WidgetSelectButton Button Selection', () => {
})
})
describe('Readonly Mode', () => {
it('disables all buttons when readonly', () => {
const options = ['option1', 'option2', 'option3']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
const formSelectButton = wrapper.findComponent({
name: 'FormSelectButton'
})
expect(formSelectButton.props('disabled')).toBe(true)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.element.disabled).toBe(true)
expect(button.classes()).toContain('cursor-not-allowed')
expect(button.classes()).toContain('opacity-50')
})
})
it('does not emit changes in readonly mode', async () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
await clickSelectButton(wrapper, 'option2')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('does not change visual state in readonly mode', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
})
})
})
describe('Option Types', () => {
it('handles string options', () => {
const options = ['apple', 'banana', 'cherry']
@@ -385,19 +346,6 @@ describe('WidgetSelectButton Button Selection', () => {
})
})
it('applies container styling', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1')
const container = wrapper.find('div').element
expect(container.className).toContain('p-1')
expect(container.className).toContain('inline-flex')
expect(container.className).toContain('justify-center')
expect(container.className).toContain('items-center')
expect(container.className).toContain('gap-1')
})
it('applies hover effects for non-selected options', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })

View File

@@ -3,7 +3,6 @@
<FormSelectButton
v-model="localValue"
:options="widget.options?.values || []"
:disabled="readonly"
class="w-full"
@update:model-value="onChange"
/>
@@ -20,7 +19,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -4,12 +4,12 @@
v-model="localValue"
:options="selectOptions"
v-bind="combinedProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs'
}"
data-capture-wheel="true"
@update:model-value="onChange"
/>
</WidgetLayoutField>
@@ -34,7 +34,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
@@ -7,6 +7,7 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useQueueStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import {
@@ -15,22 +16,27 @@ import {
} from '@/utils/widgetPropFilter'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type {
DropdownItem,
FilterOption,
SelectedKey
import {
AssetKindKey,
type DropdownItem,
type FilterOption,
type SelectedKey
} from './form/dropdown/types'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
}>()
provide(
AssetKindKey,
computed(() => props.assetKind)
)
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
@@ -43,6 +49,7 @@ const { localValue, onChange } = useWidgetValue({
})
const toastStore = useToastStore()
const queueStore = useQueueStore()
const transformCompatProps = useTransformCompatOverlayProps()
@@ -51,8 +58,15 @@ const combinedProps = computed(() => ({
...transformCompatProps.value
}))
const filterSelected = ref('all')
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'inputs', name: 'Inputs' },
{ id: 'outputs', name: 'Outputs' }
])
const selectedSet = ref<Set<SelectedKey>>(new Set())
const dropdownItems = computed<DropdownItem[]>(() => {
const inputItems = computed<DropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
@@ -60,12 +74,57 @@ const dropdownItems = computed<DropdownItem[]>(() => {
}
return values.map((value: string, index: number) => ({
id: index,
imageSrc: getMediaUrl(value),
id: `input-${index}`,
mediaSrc: getMediaUrl(value, 'input'),
name: value,
metadata: ''
}))
})
const outputItems = computed<DropdownItem[]>(() => {
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
const outputs = new Set<string>()
// Extract output images/videos from queue history
queueStore.historyTasks.forEach((task) => {
task.flatOutputs.forEach((output) => {
const isTargetType =
(props.assetKind === 'image' && output.mediaType === 'images') ||
(props.assetKind === 'video' && output.mediaType === 'video')
if (output.type === 'output' && isTargetType) {
const path = output.subfolder
? `${output.subfolder}/${output.filename}`
: output.filename
// Add [output] annotation so the preview component knows the type
const annotatedPath = `${path} [output]`
outputs.add(annotatedPath)
}
})
})
return Array.from(outputs).map((output, index) => ({
id: `output-${index}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
metadata: ''
}))
})
const allItems = computed<DropdownItem[]>(() => {
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
case 'outputs':
return outputItems.value
case 'all':
default:
return allItems.value
}
})
const mediaPlaceholder = computed(() => {
const options = props.widget.options
@@ -92,6 +151,21 @@ const mediaPlaceholder = computed(() => {
const uploadable = computed(() => props.allowUpload === true)
const acceptTypes = computed(() => {
// Be permissive with accept types because backend uses libraries
// that can handle a wide range of formats
switch (props.assetKind) {
case 'image':
return 'image/*'
case 'video':
return 'video/*'
case 'audio':
return 'audio/*'
default:
return undefined // model or unknown
}
})
watch(
localValue,
(currentValue) => {
@@ -198,19 +272,13 @@ async function handleFilesUpdate(files: File[]) {
}
}
function getMediaUrl(filename: string): string {
if (props.assetKind !== 'image') return ''
// TODO: This needs to be adapted based on actual ComfyUI API structure
return `/api/view?filename=${encodeURIComponent(filename)}&type=input`
function getMediaUrl(
filename: string,
type: 'input' | 'output' = 'input'
): string {
if (!['image', 'video'].includes(props.assetKind ?? '')) return ''
return `/api/view?filename=${encodeURIComponent(filename)}&type=${type}`
}
// TODO handle filter logic
const filterSelected = ref('all')
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'image', name: 'Inputs' },
{ id: 'video', name: 'Outputs' }
])
</script>
<template>
@@ -222,7 +290,7 @@ const filterOptions = ref<FilterOption[]>([
:placeholder="mediaPlaceholder"
:multiple="false"
:uploadable="uploadable"
:disabled="readonly"
:accept="acceptTypes"
:filter-options="filterOptions"
v-bind="combinedProps"
class="w-full"

View File

@@ -153,21 +153,6 @@ describe('WidgetTextarea Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables textarea when readonly', () => {
const widget = createMockWidget('readonly test')
const wrapper = mountComponent(widget, 'readonly test', true)
const textarea = wrapper.find('textarea')
if (!(textarea.element instanceof HTMLTextAreaElement)) {
throw new Error(
'Textarea element not found or is not an HTMLTextAreaElement'
)
}
expect(textarea.element.disabled).toBe(true)
})
})
describe('Component Rendering', () => {
it('renders textarea component', () => {
const widget = createMockWidget('test value')

View File

@@ -3,11 +3,11 @@
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
:placeholder="placeholder || widget.name || ''"
size="small"
rows="3"
data-capture-wheel="true"
@update:model-value="onChange"
/>
<LODFallback />
@@ -32,7 +32,6 @@ import { WidgetInputBaseClass } from './layout'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
placeholder?: string
}>()

View File

@@ -106,14 +106,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('modelValue')).toBe(true)
})
it('disables component in readonly mode', () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false, true)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('disabled')).toBe(true)
})
})
describe('Multiple Value Changes', () => {

View File

@@ -3,7 +3,6 @@
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</WidgetLayoutField>
@@ -25,7 +24,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -334,30 +334,6 @@ describe('WidgetTreeSelect Tree Navigation', () => {
})
})
describe('Readonly Mode', () => {
it('disables treeselect when readonly', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('disabled')).toBe(true)
})
it('does not emit changes in readonly mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
// Try to emit a change (though the component should prevent it)
await setTreeSelectValueAndEmit(wrapper, { key: '0-0-0', label: 'Test' })
// The component will still emit the event, but the disabled prop should prevent interaction
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined() // The event is emitted but the TreeSelect should be disabled
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const options = createTreeData()

View File

@@ -3,7 +3,6 @@
<TreeSelect
v-model="localValue"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
@@ -37,7 +36,6 @@ export type TreeNode = {
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -29,6 +29,7 @@ interface Props {
uploadable?: boolean
disabled?: boolean
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
@@ -89,7 +90,7 @@ watch(searchQuery, (value) => {
})
watch(
debouncedSearchQuery,
[debouncedSearchQuery, () => props.items],
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
@@ -195,6 +196,7 @@ function handleSelection(item: DropdownItem, index: number) {
:selected="selected"
:uploadable="uploadable"
:disabled="disabled"
:accept="accept"
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>

View File

@@ -15,6 +15,7 @@ interface Props {
maxSelectable: number
uploadable: boolean
disabled: boolean
accept?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -37,14 +38,13 @@ const chevronClass = computed(() =>
})
)
const theButtonStyle = computed(() => [
'bg-transparent border-0 outline-none text-zinc-400',
{
'hover:bg-zinc-500/30 hover:text-black hover:dark-theme:text-white cursor-pointer':
const theButtonStyle = computed(() =>
cn('bg-transparent border-0 outline-none text-zinc-400', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
}
])
})
)
</script>
<template>
@@ -92,6 +92,7 @@ const theButtonStyle = computed(() => [
class="opacity-0 absolute inset-0 -z-1"
:multiple="maxSelectable > 1"
:disabled="disabled"
:accept="accept"
@change="emit('file-change', $event)"
/>
</label>

View File

@@ -36,7 +36,7 @@ const searchQuery = defineModel<string>('searchQuery')
<template>
<div
class="w-103 h-[640px] pt-4 bg-white dark-theme:bg-charcoal-800 rounded-lg outline outline-offset-[-1px] outline-sand-100 dark-theme:outline-zinc-800 flex flex-col"
class="w-103 max-h-[640px] pt-4 bg-node-component-surface rounded-lg outline outline-offset-[-1px] outline-node-component-border flex flex-col"
>
<!-- Filter -->
<FormDropdownMenuFilter
@@ -67,7 +67,7 @@ const searchQuery = defineModel<string>('searchQuery')
"
>
<div
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-white dark-theme:from-neutral-900 to-transparent pointer-events-none z-10"
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-backdrop to-transparent pointer-events-none z-10"
/>
<div
v-if="items.length === 0"
@@ -84,7 +84,7 @@ const searchQuery = defineModel<string>('searchQuery')
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:image-src="item.imageSrc"
:media-src="item.mediaSrc"
:name="item.name"
:metadata="item.metadata"
:layout="layoutMode"

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, inject, ref } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode } from './types'
import { AssetKindKey, type LayoutMode } from './types'
interface Props {
index: number
selected: boolean
imageSrc: string
mediaSrc: string
name: string
metadata?: string
layout?: LayoutMode
@@ -18,23 +19,36 @@ const props = defineProps<Props>()
const emit = defineEmits<{
click: [index: number]
imageLoad: [event: Event]
mediaLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('imageLoad', event)
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
function handleVideoLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
}
}
</script>
<template>
@@ -81,10 +95,19 @@ function handleImageLoad(event: Event) {
>
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
</div>
<img
v-if="imageSrc"
:src="imageSrc"
<video
v-if="mediaSrc && isVideo"
:src="mediaSrc"
class="size-full object-cover"
preload="metadata"
muted
@loadeddata="handleVideoLoad"
/>
<LazyImage
v-else-if="mediaSrc"
:src="mediaSrc"
:alt="name"
image-class="size-full object-cover"
@load="handleImageLoad"
/>
<div

View File

@@ -1,9 +1,13 @@
import type { ComputedRef, InjectionKey } from 'vue'
import type { AssetKind } from '@/types/widgetTypes'
export type OptionId = string | number | symbol
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
imageSrc: string
mediaSrc: string // URL for image, video, or other media
name: string
metadata: string
}
@@ -19,3 +23,6 @@ export interface FilterOption {
}
export type LayoutMode = 'list' | 'grid' | 'list-small'
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')

View File

@@ -17,7 +17,7 @@ defineProps<{
<div class="relative h-6 flex items-center mr-4">
<p
v-if="widget.name"
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20 lod-toggle"
class="text-sm text-node-component-slot-text font-normal flex-1 truncate w-20 lod-toggle"
>
{{ widget.label || widget.name }}
</p>

View File

@@ -2,15 +2,13 @@ import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'bg-zinc-500/10',
'bg-node-component-widget-input-surface',
'text-node-component-widget-input',
// Outline
'border-none',
'outline',
'outline-1',
'outline-offset-[-1px]',
'outline-zinc-300/10',
'outline outline-offset-[-1px] outline-zinc-300/10',
// Rounded
'!rounded-lg',
'rounded-lg',
// Hover
'hover:outline-blue-500/80'
])