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>
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||