Fix: Simplify the widget state logic (#6741)

## Summary

Fixes the case where a value is updated in the graph but the result
doesn't reflect on the widget representation on the relevant node.

## Changes

- **What**: Uses vanilla Vue utilities instead of a special utility
- **What**: Fewer places where state could be desynced.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6741-Fix-WIP-Simplify-the-widget-state-logic-2af6d73d36508160b729db50608a2ea9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Alexander Brown
2025-11-18 14:32:22 -08:00
committed by GitHub
parent 0cff8eb357
commit 00fa9b691b
41 changed files with 86 additions and 3184 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,156 +0,0 @@
/**
* Composable for managing widget value synchronization between Vue and LiteGraph
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
*/
import { computed, toValue, ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { MaybeRefOrGetter } from '@vueuse/core'
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
/** The widget configuration from LiteGraph */
widget: SimplifiedWidget<T>
/** The current value from parent component (can be a value or a getter function) */
modelValue: MaybeRefOrGetter<T>
/** Default value if modelValue is null/undefined */
defaultValue: T
/** Emit function from component setup */
emit: (event: 'update:modelValue', value: T) => void
/** Optional value transformer before sending to LiteGraph */
transform?: (value: U) => T
}
interface UseWidgetValueReturn<T extends WidgetValue = WidgetValue, U = T> {
/** Local value for immediate UI updates */
localValue: Ref<T>
/** Handler for user interactions */
onChange: (newValue: U) => void
}
/**
* Manages widget value synchronization with LiteGraph
*
* @example
* ```vue
* const { localValue, onChange } = useWidgetValue({
* widget: props.widget,
* modelValue: props.modelValue,
* defaultValue: ''
* })
* ```
*/
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
widget,
modelValue,
defaultValue,
emit,
transform
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
// Ref for immediate UI feedback before value flows back through modelValue
const newProcessedValue = ref<T | null>(null)
// Computed that prefers the immediately processed value, then falls back to modelValue
const localValue = computed<T>(
() => newProcessedValue.value ?? toValue(modelValue) ?? defaultValue
)
// Clear newProcessedValue when modelValue updates (allowing external changes to flow through)
watch(
() => toValue(modelValue),
() => {
newProcessedValue.value = null
}
)
// Handle user changes
const onChange = (newValue: U) => {
// Handle different PrimeVue component signatures
let processedValue: T
if (transform) {
processedValue = transform(newValue)
} else {
// Ensure type safety - only cast when types are compatible
if (
typeof newValue === typeof defaultValue ||
newValue === null ||
newValue === undefined
) {
processedValue = (newValue ?? defaultValue) as T
} else {
console.warn(
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
)
processedValue = defaultValue
}
}
// Set for immediate UI feedback
newProcessedValue.value = processedValue
// Emit to parent component
emit('update:modelValue', processedValue)
}
return {
localValue: localValue as Ref<T>,
onChange
}
}
/**
* Type-specific helper for string widgets
*/
export function useStringWidgetValue(
widget: SimplifiedWidget<string>,
modelValue: string | (() => string),
emit: (event: 'update:modelValue', value: string) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: '',
emit,
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
})
}
/**
* Type-specific helper for number widgets
*/
export function useNumberWidgetValue(
widget: SimplifiedWidget<number>,
modelValue: number | (() => number),
emit: (event: 'update:modelValue', value: number) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: 0,
emit,
transform: (value: number | number[]) => {
// Handle PrimeVue Slider which can emit number | number[]
if (Array.isArray(value)) {
return value.length > 0 ? (value[0] ?? 0) : 0
}
return Number(value) || 0
}
})
}
/**
* Type-specific helper for boolean widgets
*/
export function useBooleanWidgetValue(
widget: SimplifiedWidget<boolean>,
modelValue: boolean | (() => boolean),
emit: (event: 'update:modelValue', value: boolean) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: false,
emit,
transform: (value: boolean) => Boolean(value)
})
}

View File

@@ -22,9 +22,12 @@ useExtensionService().registerExtension({
'preview',
['STRING', { multiline: true }],
app
).widget as DOMWidget<any, any>
).widget as DOMWidget<HTMLTextAreaElement, string>
showValueWidget.options.read_only = true
showValueWidget.element.readOnly = true
showValueWidget.element.disabled = true
showValueWidget.serialize = false
}

View File

@@ -8,7 +8,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node absolute',
'bg-component-node-background lg-node absolute pb-1',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
'rounded-2xl touch-none flex flex-col',

View File

@@ -59,10 +59,11 @@
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
@@ -115,18 +116,18 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
interface ProcessedWidget {
name: string
type: string
vueComponent: any
vueComponent: Component
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: unknown) => void
tooltipConfig: any
updateHandler: (value: WidgetValue) => void
tooltipConfig: TooltipOptions
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const widgets = nodeData.widgets as SafeWidgetData[]
const { widgets } = nodeData
const result: ProcessedWidget[] = []
for (const widget of widgets) {
@@ -160,14 +161,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
spec: widget.spec
}
const updateHandler = (value: unknown) => {
const updateHandler = (value: WidgetValue) => {
// Update the widget value directly
widget.value = value as WidgetValue
widget.value = value
// Skip callback for asset widgets - their callback opens the modal,
// but Vue asset mode handles selection through the dropdown
if (widget.callback && widget.type !== 'asset') {
widget.callback(value)
if (widget.type !== 'asset') {
widget.callback?.(value)
}
}

View File

@@ -1,5 +1,5 @@
import type {
TooltipDirectivePassThroughOptions,
TooltipOptions,
TooltipPassThroughMethodOptions
} from 'primevue/tooltip'
import { computed, ref, unref } from 'vue'
@@ -148,7 +148,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
* Create tooltip configuration object for v-tooltip directive
* Components wrap this in computed() for reactivity
*/
const createTooltipConfig = (text: string) => {
const createTooltipConfig = (text: string): TooltipOptions => {
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
const tooltipText = text || ''
@@ -174,7 +174,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
context.right && 'border-r-node-component-tooltip-border'
)
})
} as TooltipDirectivePassThroughOptions
}
}
}

View File

@@ -25,7 +25,7 @@ import WidgetSelect from './WidgetSelect.vue'
import AudioPreviewPlayer from './audio/AudioPreviewPlayer.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
widget: SimplifiedWidget<string | undefined>
readonly?: boolean
nodeId: string
}>()

View File

@@ -1,500 +0,0 @@
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockFile, createMockWidget } from '../testUtils'
import WidgetFileUpload from './WidgetFileUpload.vue'
describe('WidgetFileUpload File Handling', () => {
const mountComponent = (
widget: SimplifiedWidget<File[] | null>,
modelValue: File[] | null,
readonly = false
) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
...enMessages,
widgetFileUpload: {
dropPrompt: 'Drop your file or',
browseFiles: 'Browse Files'
}
}
}
})
return mount(WidgetFileUpload, {
global: {
plugins: [PrimeVue, i18n],
components: { Button, Select }
},
props: {
widget,
modelValue,
readonly
}
})
}
const mockObjectURL = 'blob:mock-url'
beforeEach(() => {
// Mock URL.createObjectURL and revokeObjectURL
global.URL.createObjectURL = vi.fn(() => mockObjectURL)
global.URL.revokeObjectURL = vi.fn()
})
describe('Initial States', () => {
it('shows upload UI when no file is selected', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
expect(wrapper.text()).toContain('Drop your file or')
expect(wrapper.text()).toContain('Browse Files')
expect(wrapper.find('button').text()).toBe('Browse Files')
})
it('renders file input with correct attributes', () => {
const widget = createMockWidget<File[] | null>(
null,
{ accept: 'image/*' },
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, null)
const fileInput = wrapper.find('input[type="file"]')
expect(fileInput.exists()).toBe(true)
expect(fileInput.attributes('accept')).toBe('image/*')
expect(fileInput.classes()).toContain('hidden')
})
})
describe('File Selection', () => {
it('triggers file input when browse button is clicked', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
const fileInput = wrapper.find('input[type="file"]')
const inputElement = fileInput.element
if (!(inputElement instanceof HTMLInputElement)) {
throw new Error('Expected HTMLInputElement')
}
const clickSpy = vi.spyOn(inputElement, 'click')
const browseButton = wrapper.find('button')
await browseButton.trigger('click')
expect(clickSpy).toHaveBeenCalled()
})
it('handles file selection', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget<File[] | null>(null, {}, mockCallback, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
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).toBeDefined()
expect(emitted![0]).toEqual([[file]])
})
it('resets file input after selection', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
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 inputElement = fileInput.element
if (!(inputElement instanceof HTMLInputElement)) {
throw new Error('Expected HTMLInputElement')
}
expect(inputElement.value).toBe('')
})
})
describe('Image File Display', () => {
it('shows image preview for image files', () => {
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])
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe(mockObjectURL)
expect(img.attributes('alt')).toBe('test.jpg')
})
it('shows select dropdown with filename for images', () => {
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])
const select = wrapper.getComponent({ name: 'Select' })
expect(select.props('modelValue')).toBe('test.jpg')
expect(select.props('options')).toEqual(['test.jpg'])
expect(select.props('disabled')).toBe(true)
})
it('shows edit and delete buttons on hover for images', () => {
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])
// The pi-pencil and pi-times classes are on the <i> elements inside the buttons
const editIcon = wrapper.find('i.pi-pencil')
const deleteIcon = wrapper.find('i.pi-times')
expect(editIcon.exists()).toBe(true)
expect(deleteIcon.exists()).toBe(true)
})
})
describe('Audio File Display', () => {
it('shows audio player for audio files', () => {
const audioFile = createMockFile('test.mp3', 'audio/mpeg')
const widget = createMockWidget<File[] | null>(
[audioFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [audioFile])
expect(wrapper.find('.pi-volume-up').exists()).toBe(true)
expect(wrapper.text()).toContain('test.mp3')
expect(wrapper.text()).toContain('1.0 KB')
})
it('shows file size for audio files', () => {
const audioFile = createMockFile('test.mp3', 'audio/mpeg', 2048)
const widget = createMockWidget<File[] | null>(
[audioFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [audioFile])
expect(wrapper.text()).toContain('2.0 KB')
})
it('shows delete button for audio files', () => {
const audioFile = createMockFile('test.mp3', 'audio/mpeg')
const widget = createMockWidget<File[] | null>(
[audioFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [audioFile])
const deleteIcon = wrapper.find('i.pi-times')
expect(deleteIcon.exists()).toBe(true)
})
})
describe('File Type Detection', () => {
const imageFiles = [
{ name: 'image.jpg', type: 'image/jpeg' },
{ name: 'image.png', type: 'image/png' }
]
const audioFiles = [
{ name: 'audio.mp3', type: 'audio/mpeg' },
{ name: 'audio.wav', type: 'audio/wav' }
]
const normalFiles = [
{ name: 'video.mp4', type: 'video/mp4' },
{ name: 'document.pdf', type: 'application/pdf' }
]
it.for(imageFiles)(
'shows image preview for $type files',
({ name, type }) => {
const file = createMockFile(name, type)
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, [file])
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('.pi-volume-up').exists()).toBe(false)
}
)
it.for(audioFiles)(
'shows audio player for $type files',
({ name, type }) => {
const file = createMockFile(name, type)
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, [file])
expect(wrapper.find('.pi-volume-up').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
}
)
it.for(normalFiles)('shows normal UI for $type files', ({ name, type }) => {
const file = createMockFile(name, type)
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, [file])
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.find('.pi-volume-up').exists()).toBe(false)
})
})
describe('File Actions', () => {
it('clears file when delete button is clicked', async () => {
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])
// Find button that contains the times icon
const buttons = wrapper.findAll('button')
const deleteButton = buttons.find((button) =>
button.find('i.pi-times').exists()
)
if (!deleteButton) {
throw new Error('Delete button with times icon not found')
}
await deleteButton.trigger('click')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual([null])
})
it('handles edit button click', async () => {
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])
// Find button that contains the pencil icon
const buttons = wrapper.findAll('button')
const editButton = buttons.find((button) =>
button.find('i.pi-pencil').exists()
)
if (!editButton) {
throw new Error('Edit button with pencil icon not found')
}
// Should not throw error when clicked (TODO: implement edit functionality)
await expect(editButton.trigger('click')).resolves.not.toThrow()
})
it('triggers file input when folder button is clicked', async () => {
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])
const fileInput = wrapper.find('input[type="file"]')
const inputElement = fileInput.element
if (!(inputElement instanceof HTMLInputElement)) {
throw new Error('Expected HTMLInputElement')
}
const clickSpy = vi.spyOn(inputElement, 'click')
// Find PrimeVue Button component with folder icon
const folderButton = wrapper.getComponent(Button)
await folderButton.trigger('click')
expect(clickSpy).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('handles empty file selection gracefully', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
const fileInput = wrapper.find('input[type="file"]')
Object.defineProperty(fileInput.element, 'files', {
value: [],
writable: false
})
await fileInput.trigger('change')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('handles missing file input gracefully', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
// Remove file input ref to simulate missing element
wrapper.vm.$refs.fileInputRef = null
// Should not throw error when method exists
const vm = wrapper.vm as any
expect(() => vm.triggerFileInput?.()).not.toThrow()
})
it('handles clearing file when no file input exists', async () => {
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])
// Remove file input ref to simulate missing element
wrapper.vm.$refs.fileInputRef = null
// Find button that contains the times icon
const buttons = wrapper.findAll('button')
const deleteButton = buttons.find((button) =>
button.find('i.pi-times').exists()
)
if (!deleteButton) {
throw new Error('Delete button with times icon not found')
}
// Should not throw error
await expect(deleteButton.trigger('click')).resolves.not.toThrow()
})
it('cleans up object URLs on unmount', () => {
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])
wrapper.unmount()
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(mockObjectURL)
})
})
})

View File

@@ -1,330 +0,0 @@
<template>
<!-- Replace entire widget with image preview when image is loaded -->
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
<div
v-if="hasImageFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above image -->
<div class="mb-2 flex items-center justify-between gap-4 px-2">
<label
v-if="widget.name"
class="text-secondary min-w-[4em] truncate text-xs"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<!-- TODO: finish once we finish value bindings with Litegraph -->
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
v-bind="transformCompatProps"
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary',
overlay: 'w-fit min-w-full'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!h-8 !w-8"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Image preview -->
<!-- TODO: change hardcoded colors when design system incorporated -->
<div class="group relative">
<img :src="imageUrl" :alt="selectedFile?.name" class="h-auto w-full" />
<!-- Darkening overlay on hover -->
<div
class="bg-opacity-0 group-hover:bg-opacity-20 pointer-events-none absolute inset-0 bg-black transition-all duration-200"
/>
<!-- Control buttons in top right on hover -->
<div
class="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<!-- Edit button -->
<button
:aria-label="$t('g.editImage')"
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
style="background-color: #262729"
@click="handleEdit"
>
<i class="pi pi-pencil text-xs text-white"></i>
</button>
<!-- Delete button -->
<button
:aria-label="$t('g.deleteImage')"
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
style="background-color: #262729"
@click="clearFile"
>
<i class="pi pi-times text-xs text-white"></i>
</button>
</div>
</div>
</div>
<!-- Audio preview when audio file is loaded -->
<div
v-else-if="hasAudioFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above audio player -->
<div class="mb-2 flex items-center justify-between gap-4 px-2">
<label
v-if="widget.name"
class="text-secondary min-w-[4em] truncate text-xs"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
v-bind="transformCompatProps"
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary',
overlay: 'w-fit min-w-full'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!h-8 !w-8"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Audio player -->
<div class="group relative px-2">
<div
class="flex items-center gap-4 rounded-lg bg-charcoal-800 p-4"
style="border: 1px solid #262729"
>
<!-- Audio icon -->
<div class="flex-shrink-0">
<i class="pi pi-volume-up text-2xl opacity-60"></i>
</div>
<!-- File info and controls -->
<div class="flex-1">
<div class="mb-1 text-sm font-medium">{{ selectedFile?.name }}</div>
<div class="text-xs opacity-60">
{{
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
}}
</div>
</div>
<!-- Control buttons -->
<div class="flex gap-1">
<!-- Delete button -->
<button
:aria-label="$t('g.deleteAudioFile')"
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-charcoal-600 focus:outline-none"
@click="clearFile"
>
<i class="pi pi-times text-sm text-white"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Show normal file upload UI when no image or audio is loaded -->
<div
v-else
class="flex w-full flex-col gap-1 rounded-lg border border-solid p-1"
:style="{ borderColor: '#262729' }"
>
<div
class="rounded-md border border-dashed p-1 transition-colors duration-200 hover:border-slate-300"
:style="{ borderColor: '#262729' }"
>
<div class="flex w-full flex-col items-center gap-2 py-4">
<span class="text-xs opacity-60">
{{ $t('widgetFileUpload.dropPrompt') }}
</span>
<div>
<Button
:label="$t('widgetFileUpload.browseFiles')"
size="small"
severity="secondary"
class="text-xs"
@click="triggerFileInput"
/>
</div>
</div>
</div>
</div>
<!-- Hidden file input always available for both states -->
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="widget.options?.accept"
:aria-label="`${$t('g.upload')} ${widget.name || $t('g.file')}`"
:multiple="false"
@change="handleFileChange"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const { widget, modelValue } = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: File[] | null]
}>()
const { localValue, onChange } = useWidgetValue({
widget,
modelValue,
defaultValue: null,
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file
const selectedFile = computed(() => {
const files = localValue.value || []
return files.length > 0 ? files[0] : null
})
// Quick file type detection for testing
const detectFileType = (file: File) => {
const type = file.type?.toLowerCase() || ''
const name = file.name?.toLowerCase() || ''
if (
type.startsWith('image/') ||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
) {
return 'image'
}
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
return 'video'
}
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
return 'audio'
}
if (type === 'application/pdf' || name.endsWith('.pdf')) {
return 'pdf'
}
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
return 'archive'
}
return 'file'
}
// Check if we have an image file
const hasImageFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
})
// Check if we have an audio file
const hasAudioFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
})
// Get image URL for preview
const imageUrl = computed(() => {
if (hasImageFile.value && selectedFile.value) {
return URL.createObjectURL(selectedFile.value)
}
return ''
})
// // Get audio URL for playback
// const audioUrl = computed(() => {
// if (hasAudioFile.value && selectedFile.value) {
// return URL.createObjectURL(selectedFile.value)
// }
// return ''
// })
// Clean up image URL when file changes
watch(imageUrl, (newUrl, oldUrl) => {
if (oldUrl && oldUrl !== newUrl) {
URL.revokeObjectURL(oldUrl)
}
})
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]
// Use the composable's onChange handler with an array
onChange([file])
// Reset input to allow selecting same file again
target.value = ''
}
}
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
})
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>

View File

@@ -2,7 +2,6 @@
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
@@ -15,18 +14,9 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const { localValue, onChange } = useNumberWidgetValue(
props.widget,
props.modelValue,
emit
)
const modelValue = defineModel<number>({ default: 0 })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
@@ -65,7 +55,7 @@ const useGrouping = computed(() => {
// Check if increment/decrement buttons should be disabled due to precision limits
const buttonsDisabled = computed(() => {
const currentValue = localValue.value ?? 0
const currentValue = modelValue.value ?? 0
return (
!Number.isFinite(currentValue) ||
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
@@ -83,7 +73,7 @@ const buttonTooltip = computed(() => {
<template>
<WidgetLayoutField :widget>
<InputNumber
v-model="localValue"
v-model="modelValue"
v-tooltip="buttonTooltip"
v-bind="filteredProps"
fluid
@@ -107,7 +97,6 @@ const buttonTooltip = computed(() => {
class: 'w-8 border-0'
}
}"
@update:model-value="onChange"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />

View File

@@ -2,7 +2,7 @@
<WidgetLayoutField :widget="widget">
<div :class="cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')">
<Slider
:model-value="[localValue]"
:model-value="[modelValue]"
v-bind="filteredProps"
class="flex-grow text-xs"
:step="stepValue"
@@ -11,7 +11,7 @@
/>
<InputNumber
:key="timesEmptied"
:model-value="localValue"
:model-value="modelValue"
v-bind="filteredProps"
:step="stepValue"
:min-fraction-digits="precision"
@@ -32,7 +32,6 @@ import InputNumber from 'primevue/inputnumber'
import { computed, ref } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
@@ -44,22 +43,16 @@ import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget, modelValue } = defineProps<{
const { widget } = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useNumberWidgetValue(widget, modelValue, emit)
const modelValue = defineModel<number>({ default: 0 })
const timesEmptied = ref(0)
const updateLocalValue = (newValue: number[] | undefined): void => {
onChange(newValue ?? [localValue.value])
if (newValue?.length) modelValue.value = newValue[0]
}
const handleNumberInputUpdate = (newValue: number | undefined) => {

View File

@@ -1,13 +1,12 @@
<template>
<WidgetLayoutField :widget="widget">
<InputText
v-model="localValue"
v-model="modelValue"
v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
:aria-label="widget.name"
size="small"
:pt="{ root: 'truncate min-w-[4ch]' }"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
@@ -16,7 +15,6 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
@@ -29,19 +27,9 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const modelValue = defineModel<string>({ default: '' })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)

View File

@@ -14,7 +14,7 @@
<Textarea
v-show="isEditing"
ref="textareaRef"
v-model="localValue"
v-model="modelValue"
:aria-label="`${$t('g.edit')} ${widget.name || $t('g.markdown')} ${$t('g.content')}`"
class="absolute inset-0 min-h-[60px] w-full resize-none"
:pt="{
@@ -24,7 +24,6 @@
}
}"
data-capture-wheel="true"
@update:model-value="onChange"
@click.stop
@keydown.stop
/>
@@ -36,35 +35,24 @@
import Textarea from 'primevue/textarea'
import { computed, nextTick, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import LODFallback from '../../components/LODFallback.vue'
const props = defineProps<{
const { widget } = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const modelValue = defineModel<string>({ default: '' })
// State
const isEditing = ref(false)
const textareaRef = ref<InstanceType<typeof Textarea> | undefined>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
// Computed
const renderedHtml = computed(() => {
return renderMarkdownToHtml(localValue.value || '')
return renderMarkdownToHtml(modelValue.value || '')
})
// Methods

View File

@@ -1,333 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import MultiSelect from 'primevue/multiselect'
import type { MultiSelectProps } from 'primevue/multiselect'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import WidgetMultiSelect from './WidgetMultiSelect.vue'
describe('WidgetMultiSelect Value Binding', () => {
const createMockWidget = (
value: WidgetValue[] = [],
options: Partial<MultiSelectProps> & { values?: WidgetValue[] } = {},
callback?: (value: WidgetValue[]) => void
): SimplifiedWidget<WidgetValue[]> => ({
name: 'test_multiselect',
type: 'array',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<WidgetValue[]>,
modelValue: WidgetValue[],
readonly = false
) => {
return mount(WidgetMultiSelect, {
global: {
plugins: [PrimeVue],
components: { MultiSelect }
},
props: {
widget,
modelValue,
readonly
}
})
}
const setMultiSelectValueAndEmit = async (
wrapper: ReturnType<typeof mount>,
values: WidgetValue[]
) => {
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
await multiselect.vm.$emit('update:modelValue', values)
return multiselect
}
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
const widget = createMockWidget([], {
values: ['option1', 'option2', 'option3']
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option2'])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['option1', 'option2']])
})
it('emits Vue event when selection is cleared', async () => {
const widget = createMockWidget(['option1'], {
values: ['option1', 'option2']
})
const wrapper = mountComponent(widget, ['option1'])
await setMultiSelectValueAndEmit(wrapper, [])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([[]])
})
it('handles single item selection', async () => {
const widget = createMockWidget([], {
values: ['single']
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['single'])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['single']])
})
it('emits update:modelValue for callback handling at parent level', async () => {
const widget = createMockWidget([], {
values: ['option1', 'option2']
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['option1'])
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['option1']])
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget(
[],
{
values: ['option1']
},
undefined
)
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['option1'])
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['option1']])
})
})
describe('Component Rendering', () => {
it('renders multiselect component', () => {
const widget = createMockWidget([], {
values: ['option1', 'option2']
})
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.exists()).toBe(true)
})
it('displays options from widget values', () => {
const options = ['apple', 'banana', 'cherry']
const widget = createMockWidget([], { values: options })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('options')).toEqual(options)
})
it('displays initial selected values', () => {
const widget = createMockWidget(['banana'], {
values: ['apple', 'banana', 'cherry']
})
const wrapper = mountComponent(widget, ['banana'])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('modelValue')).toEqual(['banana'])
})
it('applies small size styling', () => {
const widget = createMockWidget([], { values: ['test'] })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('size')).toBe('small')
})
it('uses chip display mode', () => {
const widget = createMockWidget([], { values: ['test'] })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('display')).toBe('chip')
})
it('applies text-xs class', () => {
const widget = createMockWidget([], { values: ['test'] })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.classes()).toContain('text-xs')
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const widget = createMockWidget([], {
values: ['option1', 'option2'],
placeholder: 'Select items...',
filter: true,
showClear: true
})
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('placeholder')).toBe('Select items...')
expect(multiselect.props('filter')).toBe(true)
expect(multiselect.props('showClear')).toBe(true)
})
it('excludes panel-related props', () => {
const widget = createMockWidget([], {
values: ['option1'],
overlayStyle: { color: 'red' },
panelClass: 'custom-panel'
})
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// These props should be filtered out by the prop filter
expect(multiselect.props('overlayStyle')).not.toEqual({ color: 'red' })
expect(multiselect.props('panelClass')).not.toBe('custom-panel')
})
it('handles empty values array', () => {
const widget = createMockWidget([], { values: [] })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('options')).toEqual([])
})
it('handles missing values option', () => {
const widget = createMockWidget([])
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// Should not crash, options might be undefined
expect(multiselect.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles numeric values', async () => {
const widget = createMockWidget([], {
values: [1, 2, 3, 4, 5]
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, [1, 3, 5])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([[1, 3, 5]])
})
it('handles mixed type values', async () => {
const widget = createMockWidget([], {
values: ['string', 123, true, null]
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['string', 123])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['string', 123]])
})
it('handles object values', async () => {
const objectValues = [
{ id: 1, label: 'First' },
{ id: 2, label: 'Second' }
]
const widget = createMockWidget([], {
values: objectValues,
optionLabel: 'label',
optionValue: 'id'
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, [1, 2])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([[1, 2]])
})
it('handles duplicate selections gracefully', async () => {
const widget = createMockWidget([], {
values: ['option1', 'option2']
})
const wrapper = mountComponent(widget, [])
// MultiSelect should handle duplicates internally
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option1'])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
// The actual behavior depends on PrimeVue implementation
expect(emitted![0]).toEqual([['option1', 'option1']])
})
it('handles very large option lists', () => {
const largeOptionList = Array.from(
{ length: 1000 },
(_, i) => `option${i}`
)
const widget = createMockWidget([], { values: largeOptionList })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('options')).toHaveLength(1000)
})
it('handles empty string values', async () => {
const widget = createMockWidget([], {
values: ['', 'not empty', ' ', 'normal']
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['', ' '])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['', ' ']])
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget([], { values: ['test'] })
const wrapper = mountComponent(widget, [])
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget([], { values: ['test'] })
widget.name = 'custom_multiselect'
const wrapper = mountComponent(widget, [])
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_multiselect')
})
})
})

View File

@@ -1,76 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
:options="multiSelectOptions"
v-bind="combinedProps"
class="w-full text-xs"
:aria-label="widget.name"
size="small"
display="chip"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary',
overlay: 'w-fit min-w-full'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts" generic="T extends WidgetValue = WidgetValue">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<T[]>
modelValue: T[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: T[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue<T[]>({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract multiselect options from widget options
const multiSelectOptions = computed((): T[] => {
const options = props.widget.options
if (Array.isArray(options?.values)) {
return options.values
}
return []
})
</script>

View File

@@ -87,7 +87,6 @@ import { useIntervalFn } from '@vueuse/core'
import { Button } from 'primevue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -102,14 +101,9 @@ import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
import { formatTime } from '../utils/audioUtils'
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
modelValue: string
nodeId: string
}>()
@@ -161,11 +155,9 @@ const { isPlaying, audioElementKey } = playback
// Computed for waveform animation
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
const { localValue, onChange } = useStringWidgetValue(
props.widget as SimplifiedWidget<string, Record<string, string>>,
props.modelValue,
emit
)
const modelValue = defineModel<string>({ default: '' })
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
@@ -174,9 +166,8 @@ const litegraphNode = computed(() => {
async function handleRecordingComplete(blob: Blob) {
try {
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
localValue.value = path
modelValue.value = path
lastUploadedPath = path
onChange(path)
} catch (e) {
useToastStore().addAlert('Failed to upload recorded audio')
}
@@ -278,7 +269,7 @@ async function serializeValue() {
let attempts = 0
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
const checkRecording = () => {
if (!isRecording.value && props.modelValue) {
if (!isRecording.value && modelValue.value) {
resolve(undefined)
} else if (++attempts >= maxAttempts) {
reject(new Error('Recording serialization timeout after 5 seconds'))
@@ -290,7 +281,7 @@ async function serializeValue() {
})
}
return props.modelValue || lastUploadedPath || ''
return modelValue.value || lastUploadedPath || ''
}
function registerWidgetSerialization() {

View File

@@ -2,19 +2,14 @@
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
v-model="modelValue"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault
v-else
:widget="widget"
:model-value="modelValue"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault v-else v-model="modelValue" :widget />
</template>
<script setup lang="ts">
@@ -33,18 +28,11 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
widget: SimplifiedWidget<string | undefined>
nodeType?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
function handleUpdateModelValue(value: string | number | undefined) {
emit('update:modelValue', value)
}
const modelValue = defineModel<string | undefined>()
const comboSpec = computed<ComboInputSpec | undefined>(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {

View File

@@ -1,409 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectButton from './WidgetSelectButton.vue'
function createMockWidget(
value: string = 'option1',
options: SimplifiedWidget['options'] = {},
callback?: (value: string) => void
): SimplifiedWidget<string> {
return {
name: 'test_selectbutton',
type: 'string',
value,
options,
callback
}
}
function mountComponent(
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
) {
return mount(WidgetSelectButton, {
global: {
plugins: [PrimeVue]
},
props: {
widget,
modelValue,
readonly
}
})
}
async function clickSelectButton(
wrapper: ReturnType<typeof mount>,
optionText: string
) {
const buttons = wrapper.findAll('button')
const targetButton = buttons.find((button) =>
button.text().includes(optionText)
)
if (!targetButton) {
throw new Error(`Button with text "${optionText}" not found`)
}
await targetButton.trigger('click')
return targetButton
}
describe('WidgetSelectButton Button Selection', () => {
describe('Basic Rendering', () => {
it('renders FormSelectButton component', () => {
const widget = createMockWidget('option1', {
values: ['option1', 'option2', 'option3']
})
const wrapper = mountComponent(widget, 'option1')
const formSelectButton = wrapper.findComponent({
name: 'FormSelectButton'
})
expect(formSelectButton.exists()).toBe(true)
})
it('renders buttons for each option', () => {
const options = ['first', 'second', 'third']
const widget = createMockWidget('first', { values: options })
const wrapper = mountComponent(widget, 'first')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('first')
expect(buttons[1].text()).toBe('second')
expect(buttons[2].text()).toBe('third')
})
it('handles empty options array', () => {
const widget = createMockWidget('', { values: [] })
const wrapper = mountComponent(widget, '')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(0)
})
it('handles missing values option', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(0)
})
})
describe('Selection State', () => {
it('highlights selected option', () => {
const options = ['apple', 'banana', 'cherry']
const widget = createMockWidget('banana', { values: options })
const wrapper = mountComponent(widget, 'banana')
const buttons = wrapper.findAll('button')
const selectedButton = buttons[1] // 'banana'
const unselectedButton = buttons[0] // 'apple'
expect(selectedButton.classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(selectedButton.classes()).toContain('text-primary')
expect(unselectedButton.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(unselectedButton.classes()).toContain('text-secondary')
})
it('handles no selection gracefully', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('nonexistent', { values: options })
const wrapper = mountComponent(widget, 'nonexistent')
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(button.classes()).toContain('text-secondary')
})
})
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')
// Initially 'first' is selected
let buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
// Update to 'second'
await wrapper.setProps({ modelValue: 'second' })
buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
describe('User Interactions', () => {
it('emits update:modelValue when button is clicked', async () => {
const options = ['first', 'second', 'third']
const widget = createMockWidget('first', { values: options })
const wrapper = mountComponent(widget, 'first')
await clickSelectButton(wrapper, 'second')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toEqual(['second'])
})
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(
'option1',
{ values: options },
mockCallback
)
const wrapper = mountComponent(widget, 'option1')
await clickSelectButton(wrapper, 'option2')
expect(mockCallback).toHaveBeenCalledWith('option2')
})
it('handles missing callback gracefully', async () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options }, undefined)
const wrapper = mountComponent(widget, 'option1')
await clickSelectButton(wrapper, 'option2')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toEqual(['option2'])
})
it('allows clicking same option again', async () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1')
await clickSelectButton(wrapper, 'option1')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toEqual(['option1'])
})
})
describe('Option Types', () => {
it('handles string options', () => {
const options = ['apple', 'banana', 'cherry']
const widget = createMockWidget('banana', { values: options })
const wrapper = mountComponent(widget, 'banana')
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('apple')
expect(buttons[1].text()).toBe('banana')
expect(buttons[2].text()).toBe('cherry')
})
it('handles number options', () => {
const options = [1, 2, 3]
const widget = createMockWidget('2', { values: options })
const wrapper = mountComponent(widget, '2')
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('1')
expect(buttons[1].text()).toBe('2')
expect(buttons[2].text()).toBe('3')
// The selected button should be the one with '2'
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles object options with label and value', () => {
const options = [
{ label: 'First Option', value: 'first' },
{ label: 'Second Option', value: 'second' },
{ label: 'Third Option', value: 'third' }
]
const widget = createMockWidget('second', { values: options })
const wrapper = mountComponent(widget, 'second')
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First Option')
expect(buttons[1].text()).toBe('Second Option')
expect(buttons[2].text()).toBe('Third Option')
// 'second' should be selected
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('emits correct values for object options', async () => {
const options = [
{ label: 'First', value: 'first_val' },
{ label: 'Second', value: 'second_val' }
]
const widget = createMockWidget('first_val', { values: options })
const wrapper = mountComponent(widget, 'first_val')
await clickSelectButton(wrapper, 'Second')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toEqual(['second_val'])
})
})
describe('Edge Cases', () => {
it('handles options with special characters', () => {
const options = ['@#$%^&*()', '{}[]|\\:";\'<>?,./']
const widget = createMockWidget(options[0], { values: options })
const wrapper = mountComponent(widget, options[0])
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('@#$%^&*()')
expect(buttons[1].text()).toBe('{}[]|\\:";\'<>?,./')
})
it('handles empty string options', () => {
const options = ['', 'not empty', ' ', 'normal']
const widget = createMockWidget('', { values: options })
const wrapper = mountComponent(widget, '')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Empty string is selected
})
it('handles null/undefined in options', () => {
const options: (string | null | undefined)[] = [
'valid',
null,
undefined,
'another'
]
const widget = createMockWidget('valid', { values: options })
const wrapper = mountComponent(widget, 'valid')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles very long option text', () => {
const longText =
'This is a very long option text that might cause layout issues if not handled properly'
const options = ['short', longText, 'normal']
const widget = createMockWidget('short', { values: options })
const wrapper = mountComponent(widget, 'short')
const buttons = wrapper.findAll('button')
expect(buttons[1].text()).toBe(longText)
})
it('handles large number of options', () => {
const options = Array.from({ length: 20 }, (_, i) => `option${i + 1}`)
const widget = createMockWidget('option5', { values: options })
const wrapper = mountComponent(widget, 'option5')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(20)
expect(buttons[4].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // option5 is at index 4
})
it('handles duplicate options', () => {
const options = ['duplicate', 'unique', 'duplicate', 'unique']
const widget = createMockWidget('duplicate', { values: options })
const wrapper = mountComponent(widget, 'duplicate')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
// Both 'duplicate' buttons should be highlighted (due to value matching)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
describe('Styling and Layout', () => {
it('applies proper button styling', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1')
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).toContain('flex-1')
expect(button.classes()).toContain('h-6')
expect(button.classes()).toContain('px-5')
expect(button.classes()).toContain('rounded')
expect(button.classes()).toContain('text-center')
expect(button.classes()).toContain('text-xs')
})
})
it('applies hover effects for non-selected options', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', false)
const buttons = wrapper.findAll('button')
const unselectedButton = buttons[1] // 'option2'
expect(unselectedButton.classes()).toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget('test', { values: ['test'] })
const wrapper = mountComponent(widget, 'test')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget('test', { values: ['test'] })
widget.name = 'custom_select_button'
const wrapper = mountComponent(widget, 'test')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_select_button')
})
})
})

View File

@@ -1,34 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<FormSelectButton
v-model="localValue"
:options="widget.options?.values || []"
class="w-full"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormSelectButton from './form/FormSelectButton.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
</script>

View File

@@ -1,7 +1,7 @@
<template>
<WidgetLayoutField :widget>
<Select
v-model="localValue"
v-model="modelValue"
:invalid
:options="selectOptions"
v-bind="combinedProps"
@@ -15,7 +15,6 @@
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
@@ -24,7 +23,6 @@
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -36,21 +34,16 @@ import {
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
}>()
interface Props {
widget: SimplifiedWidget<string | undefined>
}
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
const props = defineProps<Props>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
const modelValue = defineModel<string | undefined>({
default(props: Props) {
return props.widget.options?.values?.[0] || ''
}
})
// Transform compatibility props for overlay positioning
@@ -67,12 +60,12 @@ const selectOptions = computed(() => {
return []
})
const invalid = computed(
() => !!localValue.value && !selectOptions.value.includes(localValue.value)
() => !!modelValue.value && !selectOptions.value.includes(modelValue.value)
)
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value,
...(invalid.value ? { placeholder: `${localValue.value}` } : {})
...(invalid.value ? { placeholder: `${modelValue.value}` } : {})
}))
</script>

View File

@@ -2,7 +2,6 @@
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, toRef, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -27,31 +26,27 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
interface Props {
widget: SimplifiedWidget<string | undefined>
nodeType?: string
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
isAssetMode?: boolean
defaultLayoutMode?: LayoutMode
}>()
}
const props = defineProps<Props>()
provide(
AssetKindKey,
computed(() => props.assetKind)
)
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: () => props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
const modelValue = defineModel<string | undefined>({
default(props: Props) {
return props.widget.options?.values?.[0] || ''
}
})
const toastStore = useToastStore()
@@ -218,7 +213,7 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
localValue,
modelValue,
(currentValue) => {
if (currentValue !== undefined) {
const item = dropdownItems.value.find(
@@ -241,15 +236,15 @@ function updateSelectedItems(selectedItems: Set<SelectedKey>) {
id = selectedItems.values().next().value!
}
if (id == null) {
onChange(undefined)
modelValue.value = undefined
return
}
const name = dropdownItems.value.find((item) => item.id === id)?.name
if (!name) {
onChange(undefined)
modelValue.value = undefined
return
}
onChange(name)
modelValue.value = name
}
// Upload file function (copied from useNodeImageUpload.ts)
@@ -318,7 +313,7 @@ async function handleFilesUpdate(files: File[]) {
}
// 3. Update widget value to the first uploaded file
onChange(uploadedPaths[0])
modelValue.value = uploadedPaths[0]
// 4. Trigger callback to notify underlying LiteGraph widget
if (props.widget.callback) {

View File

@@ -1,16 +1,17 @@
<template>
<div class="widget-expands relative">
<Textarea
v-model="localValue"
v-model="modelValue"
v-bind="filteredProps"
:class="
cn(WidgetInputBaseClass, 'size-full text-xs lod-toggle resize-none')
"
:placeholder="placeholder || widget.name || ''"
:aria-label="widget.name"
:readonly="widget.options?.read_only"
:disabled="widget.options?.read_only"
fluid
data-capture-wheel="true"
@update:model-value="onChange"
/>
<LODFallback />
</div>
@@ -20,7 +21,6 @@
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
@@ -31,24 +31,14 @@ import {
import LODFallback from '../../components/LODFallback.vue'
import { WidgetInputBaseClass } from './layout'
const props = defineProps<{
const { widget, placeholder = '' } = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const modelValue = defineModel<string>({ default: '' })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,11 +1,10 @@
<template>
<WidgetLayoutField :widget>
<ToggleSwitch
v-model="localValue"
v-model="modelValue"
v-bind="filteredProps"
class="ml-auto block"
:aria-label="widget.name"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
@@ -14,7 +13,6 @@
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
@@ -23,23 +21,13 @@ import {
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
const { widget } = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useBooleanWidgetValue(
props.widget,
props.modelValue,
emit
)
const modelValue = defineModel<boolean>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,515 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import TreeSelect from 'primevue/treeselect'
import type { TreeSelectProps } from 'primevue/treeselect'
import { describe, expect, it, vi } from 'vitest'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import WidgetTreeSelect from './WidgetTreeSelect.vue'
import type { TreeNode } from './WidgetTreeSelect.vue'
const createTreeData = (): TreeNode[] => [
{
key: '0',
label: 'Documents',
data: 'Documents Folder',
children: [
{
key: '0-0',
label: 'Work',
data: 'Work Folder',
children: [
{
key: '0-0-0',
label: 'Expenses.doc',
data: 'Expenses Document',
leaf: true
},
{
key: '0-0-1',
label: 'Resume.doc',
data: 'Resume Document',
leaf: true
}
]
},
{
key: '0-1',
label: 'Home',
data: 'Home Folder',
children: [
{
key: '0-1-0',
label: 'Invoices.txt',
data: 'Invoices for this month',
leaf: true
}
]
}
]
},
{
key: '1',
label: 'Events',
data: 'Events Folder',
children: [
{ key: '1-0', label: 'Meeting', data: 'Meeting', leaf: true },
{
key: '1-1',
label: 'Product Launch',
data: 'Product Launch',
leaf: true
},
{
key: '1-2',
label: 'Report Review',
data: 'Report Review',
leaf: true
}
]
}
]
describe('WidgetTreeSelect Tree Navigation', () => {
const createMockWidget = (
value: WidgetValue = null,
options: Partial<TreeSelectProps> = {},
callback?: (value: WidgetValue) => void
): SimplifiedWidget<WidgetValue> => ({
name: 'test_treeselect',
type: 'object',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<WidgetValue>,
modelValue: WidgetValue,
readonly = false
) => {
return mount(WidgetTreeSelect, {
global: {
plugins: [PrimeVue],
components: { TreeSelect }
},
props: {
widget,
modelValue,
readonly
}
})
}
const setTreeSelectValueAndEmit = async (
wrapper: ReturnType<typeof mount>,
value: unknown
) => {
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
await treeSelect.vm.$emit('update:modelValue', value)
return treeSelect
}
describe('Component Rendering', () => {
it('renders treeselect component', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.exists()).toBe(true)
})
it('displays tree options from widget options', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(options)
})
it('displays initial selected value', () => {
const options = createTreeData()
const selectedValue = {
key: '0-0-0',
label: 'Expenses.doc',
data: 'Expenses Document',
leaf: true
}
const widget = createMockWidget(selectedValue, { options })
const wrapper = mountComponent(widget, selectedValue)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('modelValue')).toEqual(selectedValue)
})
it('applies small size styling', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('size')).toBe('small')
})
it('applies text-xs class', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.classes()).toContain('text-xs')
})
})
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null)
const selectedNode = { key: '0-0-0', label: 'Expenses.doc' }
await setTreeSelectValueAndEmit(wrapper, selectedNode)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNode])
})
it('emits Vue event when selection is cleared', async () => {
const options = createTreeData()
const initialValue = { key: '0-0-0', label: 'Expenses.doc' }
const widget = createMockWidget(initialValue, { options })
const wrapper = mountComponent(widget, initialValue)
await setTreeSelectValueAndEmit(wrapper, null)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([null])
})
it('handles callback when widget value changes', async () => {
const mockCallback = vi.fn()
const options = createTreeData()
const widget = createMockWidget(null, { options }, mockCallback)
const wrapper = mountComponent(widget, null)
// Test that the treeselect has the callback widget
expect(widget.callback).toBe(mockCallback)
// Manually trigger the composable's onChange to test callback
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.exists()).toBe(true)
})
it('handles missing callback gracefully', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options }, undefined)
const wrapper = mountComponent(widget, null)
const selectedNode = { key: '0-1-0', label: 'Invoices.txt' }
await setTreeSelectValueAndEmit(wrapper, selectedNode)
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNode])
})
})
describe('Tree Structure Handling', () => {
it('handles flat tree structure', () => {
const flatOptions: TreeNode[] = [
{ key: 'item1', label: 'Item 1', leaf: true },
{ key: 'item2', label: 'Item 2', leaf: true },
{ key: 'item3', label: 'Item 3', leaf: true }
]
const widget = createMockWidget(null, { options: flatOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(flatOptions)
})
it('handles nested tree structure', () => {
const nestedOptions = createTreeData()
const widget = createMockWidget(null, { options: nestedOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(nestedOptions)
})
it('handles tree with mixed leaf and parent nodes', () => {
const mixedOptions: TreeNode[] = [
{ key: 'leaf1', label: 'Leaf Node', leaf: true },
{
key: 'parent1',
label: 'Parent Node',
children: [{ key: 'child1', label: 'Child Node', leaf: true }]
},
{ key: 'leaf2', label: 'Another Leaf', leaf: true }
]
const widget = createMockWidget(null, { options: mixedOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(mixedOptions)
})
it('handles deeply nested tree structure', () => {
const deepOptions: TreeNode[] = [
{
key: 'level1',
label: 'Level 1',
children: [
{
key: 'level2',
label: 'Level 2',
children: [
{
key: 'level3',
label: 'Level 3',
children: [{ key: 'level4', label: 'Level 4', leaf: true }]
}
]
}
]
}
]
const widget = createMockWidget(null, { options: deepOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(deepOptions)
})
})
describe('Selection Modes', () => {
it('handles single selection mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
selectionMode: 'single'
})
const wrapper = mountComponent(widget, null)
const selectedNode = { key: '0-0-0', label: 'Expenses.doc' }
await setTreeSelectValueAndEmit(wrapper, selectedNode)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNode])
})
it('handles multiple selection mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
selectionMode: 'multiple'
})
const wrapper = mountComponent(widget, null)
const selectedNodes = [
{ key: '0-0-0', label: 'Expenses.doc' },
{ key: '1-0', label: 'Meeting' }
]
await setTreeSelectValueAndEmit(wrapper, selectedNodes)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNodes])
})
it('handles checkbox selection mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
selectionMode: 'checkbox'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('selectionMode')).toBe('checkbox')
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
placeholder: 'Select a node...',
filter: true,
showClear: true,
selectionMode: 'single'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('placeholder')).toBe('Select a node...')
expect(treeSelect.props('filter')).toBe(true)
expect(treeSelect.props('showClear')).toBe(true)
expect(treeSelect.props('selectionMode')).toBe('single')
})
it('excludes panel-related props', () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
inputClass: 'custom-input',
inputStyle: { color: 'red' },
panelClass: 'custom-panel'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
// These props should be filtered out by the widgetPropFilter
const inputClass = treeSelect.props('inputClass')
const inputStyle = treeSelect.props('inputStyle')
// Either undefined or null are acceptable as "excluded"
expect(inputClass == null).toBe(true)
expect(inputStyle == null).toBe(true)
expect(treeSelect.exists()).toBe(true)
})
it('handles empty options gracefully', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual([])
})
it('handles missing options gracefully', () => {
const widget = createMockWidget(null)
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
// Should not crash, options might be undefined
expect(treeSelect.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles malformed tree nodes', () => {
const malformedOptions: unknown[] = [
{ key: 'empty', label: 'Empty Object' }, // Valid object to prevent issues
{ key: 'random', label: 'Random', randomProp: 'value' } // Object with extra properties
]
const widget = createMockWidget(null, {
options: malformedOptions as TreeNode[]
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(malformedOptions)
})
it('handles nodes with missing keys', () => {
const noKeyOptions = [
{ key: 'generated-1', label: 'No Key 1', leaf: true },
{ key: 'generated-2', label: 'No Key 2', leaf: true }
] as TreeNode[]
const widget = createMockWidget(null, { options: noKeyOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(noKeyOptions)
})
it('handles nodes with missing labels', () => {
const noLabelOptions: TreeNode[] = [
{ key: 'key1', leaf: true },
{ key: 'key2', leaf: true }
]
const widget = createMockWidget(null, { options: noLabelOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(noLabelOptions)
})
it('handles very large tree structure', () => {
const largeTree: TreeNode[] = Array.from({ length: 100 }, (_, i) => ({
key: `node${i}`,
label: `Node ${i}`,
children: Array.from({ length: 10 }, (_, j) => ({
key: `node${i}-${j}`,
label: `Child ${j}`,
leaf: true
}))
}))
const widget = createMockWidget(null, { options: largeTree })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toHaveLength(100)
})
it('handles tree with circular references safely', () => {
// Create nodes that could potentially have circular references
const circularOptions: TreeNode[] = [
{
key: 'parent',
label: 'Parent',
children: [{ key: 'child1', label: 'Child 1', leaf: true }]
}
]
const widget = createMockWidget(null, { options: circularOptions })
expect(() => mountComponent(widget, null)).not.toThrow()
})
it('handles nodes with special characters', () => {
const specialCharOptions: TreeNode[] = [
{ key: '@#$%^&*()', label: 'Special Chars @#$%', leaf: true },
{
key: '{}[]|\\:";\'<>?,./`~',
label: 'More Special {}[]|\\',
leaf: true
}
]
const widget = createMockWidget(null, { options: specialCharOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(specialCharOptions)
})
it('handles unicode in node labels', () => {
const unicodeOptions: TreeNode[] = [
{ key: 'unicode1', label: '🌟 Unicode Star', leaf: true },
{ key: 'unicode2', label: '中文 Chinese', leaf: true },
{ key: 'unicode3', label: 'العربية Arabic', leaf: true }
]
const widget = createMockWidget(null, { options: unicodeOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(unicodeOptions)
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget(null, { options: [] })
widget.name = 'custom_treeselect'
const wrapper = mountComponent(widget, null)
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_treeselect')
})
})
})

View File

@@ -1,72 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<TreeSelect
v-model="localValue"
v-bind="combinedProps"
class="w-full text-xs"
:aria-label="widget.name"
size="small"
:pt="{
dropdownIcon: 'text-component-node-foreground-secondary',
overlay: 'w-fit min-w-full'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
export type TreeNode = {
key: string
label?: string
data?: unknown
children?: TreeNode[]
leaf?: boolean
selectable?: boolean
}
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
</script>

View File

@@ -24,12 +24,6 @@ const WidgetSelect = defineAsyncComponent(
const WidgetColorPicker = defineAsyncComponent(
() => import('../components/WidgetColorPicker.vue')
)
const WidgetMultiSelect = defineAsyncComponent(
() => import('../components/WidgetMultiSelect.vue')
)
const WidgetSelectButton = defineAsyncComponent(
() => import('../components/WidgetSelectButton.vue')
)
const WidgetTextarea = defineAsyncComponent(
() => import('../components/WidgetTextarea.vue')
)
@@ -42,12 +36,6 @@ const WidgetImageCompare = defineAsyncComponent(
const WidgetGalleria = defineAsyncComponent(
() => import('../components/WidgetGalleria.vue')
)
const WidgetFileUpload = defineAsyncComponent(
() => import('../components/WidgetFileUpload.vue')
)
const WidgetTreeSelect = defineAsyncComponent(
() => import('../components/WidgetTreeSelect.vue')
)
const WidgetMarkdown = defineAsyncComponent(
() => import('../components/WidgetMarkdown.vue')
)
@@ -71,7 +59,6 @@ export const FOR_TESTING = {
WidgetAudioUI,
WidgetButton,
WidgetColorPicker,
WidgetFileUpload,
WidgetInputNumber,
WidgetInputText,
WidgetMarkdown,
@@ -124,18 +111,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
'color',
{ component: WidgetColorPicker, aliases: ['COLOR'], essential: false }
],
[
'multiselect',
{ component: WidgetMultiSelect, aliases: ['MULTISELECT'], essential: false }
],
[
'selectbutton',
{
component: WidgetSelectButton,
aliases: ['SELECTBUTTON'],
essential: false
}
],
[
'textarea',
{
@@ -157,18 +132,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
'galleria',
{ component: WidgetGalleria, aliases: ['GALLERIA'], essential: false }
],
[
'fileupload',
{
component: WidgetFileUpload,
aliases: ['FILEUPLOAD', 'file'],
essential: false
}
],
[
'treeselect',
{ component: WidgetTreeSelect, aliases: ['TREESELECT'], essential: false }
],
[
'markdown',
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }

View File

@@ -1,33 +0,0 @@
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
/**
* Creates a mock SimplifiedWidget for testing Vue Node widgets.
* This utility function is shared across widget component tests to ensure consistency.
*/
export function createMockWidget<T extends WidgetValue>(
value: T = null as T,
options: Record<string, any> = {},
callback?: (value: T) => void,
overrides: Partial<SimplifiedWidget<T>> = {}
): SimplifiedWidget<T> {
return {
name: 'test_widget',
type: 'default',
value,
options,
callback,
...overrides
}
}
/**
* Creates a mock file for testing file upload widgets.
*/
export function createMockFile(name: string, type: string, size = 1024): File {
const file = new File(['mock content'], name, { type })
Object.defineProperty(file, 'size', {
value: size,
writable: false
})
return file
}

View File

@@ -49,9 +49,9 @@ describe('WidgetSelect Value Binding', () => {
options: Partial<
SelectProps & { values?: string[]; return_index?: boolean }
> = {},
callback?: (value: string | number | undefined) => void,
callback?: (value: string | undefined) => void,
spec?: ComboInputSpec
): SimplifiedWidget<string | number | undefined> => ({
): SimplifiedWidget<string | undefined> => ({
name: 'test_select',
type: 'combo',
value,
@@ -64,8 +64,8 @@ describe('WidgetSelect Value Binding', () => {
})
const mountComponent = (
widget: SimplifiedWidget<string | number | undefined>,
modelValue: string | number | undefined,
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
readonly = false
) => {
return mount(WidgetSelect, {

View File

@@ -1,503 +0,0 @@
import {
type MockedFunction,
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { ref } from 'vue'
import {
useBooleanWidgetValue,
useNumberWidgetValue,
useStringWidgetValue,
useWidgetValue
} from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
describe('useWidgetValue', () => {
let mockWidget: SimplifiedWidget<string>
let mockEmit: MockedFunction<(event: 'update:modelValue', value: any) => void>
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockWidget = {
name: 'testWidget',
type: 'string',
value: 'initial',
callback: vi.fn()
}
mockEmit = vi.fn()
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
consoleWarnSpy.mockRestore()
})
describe('basic functionality', () => {
it('should initialize with modelValue', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: 'test value',
defaultValue: '',
emit: mockEmit
})
expect(localValue.value).toBe('test value')
})
it('should use defaultValue when modelValue is null', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: null as any,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('default')
})
it('should use defaultValue when modelValue is undefined', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: undefined as any,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('default')
})
})
describe('onChange handler', () => {
it('should update localValue immediately', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('new value')
expect(localValue.value).toBe('new value')
})
it('should emit update:modelValue event', () => {
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('new value')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
})
// useGraphNodeMaanger's createWrappedWidgetCallback makes the callback right now instead of useWidgetValue
// it('should call widget callback if it exists', () => {
// const { onChange } = useWidgetValue({
// widget: mockWidget,
// modelValue: 'initial',
// defaultValue: '',
// emit: mockEmit
// })
// onChange('new value')
// expect(mockWidget.callback).toHaveBeenCalledWith('new value')
// })
it('should not error if widget callback is undefined', () => {
const widgetWithoutCallback = { ...mockWidget, callback: undefined }
const { onChange } = useWidgetValue({
widget: widgetWithoutCallback,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
expect(() => onChange('new value')).not.toThrow()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
})
it('should handle null values', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: 'default',
emit: mockEmit
})
onChange(null as any)
expect(localValue.value).toBe('default')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
})
it('should handle undefined values', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: 'default',
emit: mockEmit
})
onChange(undefined as any)
expect(localValue.value).toBe('default')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
})
})
describe('type safety', () => {
it('should handle type mismatches with warning', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit
})
// Pass string to number widget
onChange('not a number' as any)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'useWidgetValue: Type mismatch for widget numberWidget. Expected number, got string'
)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0) // Uses defaultValue
})
it('should accept values of matching type', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit
})
onChange(25)
expect(consoleWarnSpy).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 25)
})
})
describe('transform function', () => {
it('should apply transform function to new values', () => {
const transform = vi.fn((value: string) => value.toUpperCase())
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit,
transform
})
onChange('hello')
expect(transform).toHaveBeenCalledWith('hello')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'HELLO')
})
it('should skip type checking when transform is provided', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const transform = (value: string) => parseInt(value, 10) || 0
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit,
transform
})
onChange('123')
expect(consoleWarnSpy).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 123)
})
})
describe('external updates', () => {
it('should update localValue when modelValue changes', async () => {
const modelValue = ref('initial')
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value,
defaultValue: '',
emit: mockEmit
})
expect(localValue.value).toBe('initial')
// Simulate parent updating modelValue
modelValue.value = 'updated externally'
// Re-create the composable with new value (simulating prop change)
const { localValue: newLocalValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value,
defaultValue: '',
emit: mockEmit
})
expect(newLocalValue.value).toBe('updated externally')
})
it('should handle external null values', async () => {
const modelValue = ref<string | null>('initial')
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value!,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('initial')
// Simulate external update to null
modelValue.value = null
const { localValue: newLocalValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value as any,
defaultValue: 'default',
emit: mockEmit
})
expect(newLocalValue.value).toBe('default')
})
})
describe('useStringWidgetValue helper', () => {
it('should handle string values correctly', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: 'hello',
callback: vi.fn()
}
const { localValue, onChange } = useStringWidgetValue(
stringWidget,
'initial',
mockEmit
)
expect(localValue.value).toBe('initial')
onChange('new string')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new string')
})
it('should transform undefined to empty string', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: '',
callback: vi.fn()
}
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
onChange(undefined as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
})
it('should convert non-string values to string', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: '',
callback: vi.fn()
}
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
onChange(123 as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '123')
})
})
describe('useNumberWidgetValue helper', () => {
it('should handle number values correctly', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { localValue, onChange } = useNumberWidgetValue(
numberWidget,
25,
mockEmit
)
expect(localValue.value).toBe(25)
onChange(75)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 75)
})
it('should handle array values from PrimeVue Slider', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
// PrimeVue Slider can emit number[]
onChange([42, 100] as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
})
it('should handle empty array', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
onChange([] as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
})
it('should convert string numbers', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 0,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
onChange('42' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
})
it('should handle invalid number conversions', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 0,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
onChange('not-a-number' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
})
})
describe('useBooleanWidgetValue helper', () => {
it('should handle boolean values correctly', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { localValue, onChange } = useBooleanWidgetValue(
boolWidget,
true,
mockEmit
)
expect(localValue.value).toBe(true)
onChange(false)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
})
it('should convert truthy values to true', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { onChange } = useBooleanWidgetValue(boolWidget, false, mockEmit)
onChange('truthy' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', true)
})
it('should convert falsy values to false', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { onChange } = useBooleanWidgetValue(boolWidget, true, mockEmit)
onChange(0 as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
})
})
describe('edge cases', () => {
it('should handle rapid onChange calls', () => {
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('value1')
onChange('value2')
onChange('value3')
expect(mockEmit).toHaveBeenCalledTimes(3)
expect(mockEmit).toHaveBeenNthCalledWith(1, 'update:modelValue', 'value1')
expect(mockEmit).toHaveBeenNthCalledWith(2, 'update:modelValue', 'value2')
expect(mockEmit).toHaveBeenNthCalledWith(3, 'update:modelValue', 'value3')
})
it('should handle widget with all properties undefined', () => {
const minimalWidget = {
name: 'minimal',
type: 'unknown'
} as SimplifiedWidget<any>
const { localValue, onChange } = useWidgetValue({
widget: minimalWidget,
modelValue: 'test',
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('test')
expect(() => onChange('new')).not.toThrow()
})
})
})

View File

@@ -31,7 +31,7 @@ import { assetService } from '@/platform/assets/services/assetService'
const mockAssetServiceEligible = vi.mocked(assetService.isAssetBrowserEligible)
describe('WidgetSelect asset mode', () => {
const createWidget = (): SimplifiedWidget<string | number | undefined> => ({
const createWidget = (): SimplifiedWidget<string | undefined> => ({
name: 'ckpt_name',
type: 'combo',
value: undefined,

View File

@@ -25,7 +25,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
getOptionLabel?: (value: string | null) => string
} = {},
spec?: ComboInputSpec
): SimplifiedWidget<string | number | undefined> => ({
): SimplifiedWidget<string | undefined> => ({
name: 'test_image_select',
type: 'combo',
value,
@@ -37,8 +37,8 @@ describe('WidgetSelectDropdown custom label mapping', () => {
})
const mountComponent = (
widget: SimplifiedWidget<string | number | undefined>,
modelValue: string | number | undefined,
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
assetKind: 'image' | 'video' | 'audio' = 'image'
): VueWrapper<WidgetSelectDropdownInstance> => {
return mount(WidgetSelectDropdown, {

View File

@@ -11,7 +11,6 @@ const {
WidgetAudioUI,
WidgetButton,
WidgetColorPicker,
WidgetFileUpload,
WidgetInputNumber,
WidgetInputText,
WidgetMarkdown,
@@ -88,12 +87,6 @@ describe('widgetRegistry', () => {
expect(getComponent('COLOR', 'color')).toBe(WidgetColorPicker)
})
it('should map file types to file upload widget', () => {
expect(getComponent('file', 'file')).toBe(WidgetFileUpload)
expect(getComponent('fileupload', 'file')).toBe(WidgetFileUpload)
expect(getComponent('FILEUPLOAD', 'file')).toBe(WidgetFileUpload)
})
it('should map button types to button widget', () => {
expect(getComponent('button', '')).toBe(WidgetButton)
expect(getComponent('BUTTON', '')).toBe(WidgetButton)