merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -0,0 +1,248 @@
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import type { ButtonProps } from 'primevue/button'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
describe('WidgetButton Interactions', () => {
const createMockWidget = (
options: Partial<ButtonProps> = {},
callback?: () => void,
name: string = 'test_button'
): SimplifiedWidget<void> => ({
name,
type: 'button',
value: undefined,
options,
callback
})
const mountComponent = (widget: SimplifiedWidget<void>, readonly = false) => {
return mount(WidgetButton, {
global: {
plugins: [PrimeVue],
components: { Button }
},
props: {
widget,
readonly
}
})
}
const clickButton = async (wrapper: ReturnType<typeof mount>) => {
const button = wrapper.findComponent({ name: 'Button' })
await button.trigger('click')
return button
}
describe('Click Handling', () => {
it('calls callback when button is clicked', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget)
await clickButton(wrapper)
expect(mockCallback).toHaveBeenCalledTimes(1)
})
it('does not call callback when button is readonly', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget, true)
await clickButton(wrapper)
expect(mockCallback).not.toHaveBeenCalled()
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget({}, undefined)
const wrapper = mountComponent(widget)
// Should not throw error when clicking without callback
await expect(clickButton(wrapper)).resolves.toBeDefined()
})
it('calls callback multiple times when clicked multiple times', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget)
const numClicks = 8
await clickButton(wrapper)
for (let i = 0; i < numClicks; i++) {
await clickButton(wrapper)
}
expect(mockCallback).toHaveBeenCalledTimes(numClicks)
})
})
describe('Component Rendering', () => {
it('renders button component', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.exists()).toBe(true)
})
it('renders widget label when name is provided', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget)
const label = wrapper.find('label')
expect(label.exists()).toBe(true)
expect(label.text()).toBe('test_button')
})
it('does not render label when widget name is empty', () => {
const widget = createMockWidget({}, undefined, '')
const wrapper = mountComponent(widget)
const label = wrapper.find('label')
expect(label.exists()).toBe(false)
})
it('sets button size to small', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('size')).toBe('small')
})
it('passes widget options to button component', () => {
const buttonOptions = {
label: 'Custom Label',
icon: 'pi pi-check',
severity: 'success' as const
}
const widget = createMockWidget(buttonOptions)
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('label')).toBe('Custom Label')
expect(button.props('icon')).toBe('pi pi-check')
expect(button.props('severity')).toBe('success')
})
})
describe('Readonly Mode', () => {
it('disables button when readonly', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget, true)
// Test the actual DOM button element instead of the Vue component props
const buttonElement = wrapper.find('button')
expect(buttonElement.element.disabled).toBe(true)
})
it('enables button when not readonly', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget, false)
// Test the actual DOM button element instead of the Vue component props
const buttonElement = wrapper.find('button')
expect(buttonElement.element.disabled).toBe(false)
})
})
describe('Widget Options', () => {
it('handles button with text only', () => {
const widget = createMockWidget({ label: 'Click Me' })
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('label')).toBe('Click Me')
expect(button.props('icon')).toBeNull()
})
it('handles button with icon only', () => {
const widget = createMockWidget({ icon: 'pi pi-star' })
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('icon')).toBe('pi pi-star')
})
it('handles button with both text and icon', () => {
const widget = createMockWidget({
label: 'Save',
icon: 'pi pi-save'
})
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('label')).toBe('Save')
expect(button.props('icon')).toBe('pi pi-save')
})
it.for([
'secondary',
'success',
'info',
'warning',
'danger',
'help',
'contrast'
] as const)('handles button severity: %s', (severity) => {
const widget = createMockWidget({ severity })
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('severity')).toBe(severity)
})
it.for(['outlined', 'text'] as const)(
'handles button variant: %s',
(variant) => {
const widget = createMockWidget({ variant })
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.props('variant')).toBe(variant)
}
)
})
describe('Edge Cases', () => {
it('handles widget with no options', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget)
const button = wrapper.findComponent({ name: 'Button' })
expect(button.exists()).toBe(true)
})
it('handles callback that throws error', async () => {
const mockCallback = vi.fn(() => {
throw new Error('Callback error')
})
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget)
// Should not break the component when callback throws
await expect(clickButton(wrapper)).rejects.toThrow('Callback error')
expect(mockCallback).toHaveBeenCalledTimes(1)
})
it('handles rapid consecutive clicks', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget)
// Simulate rapid clicks
const clickPromises = Array.from({ length: 16 }, () =>
clickButton(wrapper)
)
await Promise.all(clickPromises)
expect(mockCallback).toHaveBeenCalledTimes(16)
})
})
})

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
BADGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback()
}
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col gap-1">
<div
class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded max-h-[48rem]"
>
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ChartData } from 'chart.js'
import Chart from 'primevue/chart'
import { computed } from 'vue'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const chartType = computed(() => props.widget.options?.type ?? 'line')
const chartData = computed(() => value.value || { labels: [], datasets: [] })
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#FFF',
usePointStyle: true,
pointStyle: 'circle'
}
}
},
scales: {
x: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: true,
color: '#9FA2BD',
drawTicks: false,
drawOnChartArea: true,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
},
y: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: false,
drawTicks: false,
drawOnChartArea: false,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
}
}
}))
</script>

View File

@@ -0,0 +1,304 @@
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import type { ColorPickerProps } from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetColorPicker from './WidgetColorPicker.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
describe('WidgetColorPicker Value Binding', () => {
const createMockWidget = (
value: string = '#000000',
options: Partial<ColorPickerProps> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_color_picker',
type: 'color',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
) => {
return mount(WidgetColorPicker, {
global: {
plugins: [PrimeVue],
components: {
ColorPicker,
WidgetLayoutField
}
},
props: {
widget,
modelValue,
readonly
}
})
}
const setColorPickerValue = async (
wrapper: ReturnType<typeof mount>,
value: unknown
) => {
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
await colorPicker.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when color changes', async () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const emitted = await setColorPickerValue(wrapper, '#00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('handles different color formats', async () => {
const widget = createMockWidget('#ffffff')
const wrapper = mountComponent(widget, '#ffffff')
const emitted = await setColorPickerValue(wrapper, '#123abc')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#123abc')
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('#000000', {}, undefined)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
// Should still emit Vue event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff00ff')
})
it('normalizes bare hex without # to #hex on emit', async () => {
const widget = createMockWidget('ff0000')
const wrapper = mountComponent(widget, 'ff0000')
const emitted = await setColorPickerValue(wrapper, '00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes rgb() strings to #hex on emit', async () => {
const widget = createMockWidget('#000000')
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff0000')
})
it('normalizes hsb() strings to #hex on emit', async () => {
const widget = createMockWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes HSB object values to #hex on emit', async () => {
const widget = createMockWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, {
h: 240,
s: 100,
b: 100
})
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#0000ff')
})
})
describe('Component Rendering', () => {
it('renders color picker component', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.exists()).toBe(true)
})
it('normalizes display to a single leading #', () => {
// Case 1: model value already includes '#'
let widget = createMockWidget('#ff0000')
let wrapper = mountComponent(widget, '#ff0000')
let colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
// Case 2: model value missing '#'
widget = createMockWidget('ff0000')
wrapper = mountComponent(widget, 'ff0000')
colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
})
it('renders layout field wrapper', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
})
it('displays current color value as text', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#ff0000')
})
it('updates color text when value changes', async () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
await setColorPickerValue(wrapper, '#00ff00')
// Need to check the local state update
const colorText = wrapper.find('[data-testid="widget-color-text"]')
// Be specific about the displayed value including the leading '#'
expect.soft(colorText.text()).toBe('#00ff00')
})
it('uses default color when no value provided', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
// Should use the default value from the composable
expect(colorPicker.exists()).toBe(true)
})
})
describe('Readonly Mode', () => {
it('disables color picker when readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', true)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(true)
})
it('enables color picker when not readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', false)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(false)
})
})
describe('Color Formats', () => {
it('handles valid hex colors', async () => {
const validHexColors = [
'#000000',
'#ffffff',
'#ff0000',
'#00ff00',
'#0000ff',
'#123abc'
]
for (const color of validHexColors) {
const widget = createMockWidget(color)
const wrapper = mountComponent(widget, color)
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe(color)
}
})
it('handles short hex colors', () => {
const widget = createMockWidget('#fff')
const wrapper = mountComponent(widget, '#fff')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#fff')
})
it('passes widget options to color picker', () => {
const colorOptions = {
format: 'hex' as const,
inline: true
}
const widget = createMockWidget('#ff0000', colorOptions)
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('format')).toBe('hex')
expect(colorPicker.props('inline')).toBe(true)
})
})
describe('Widget Layout Integration', () => {
it('passes widget to layout field', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget')).toEqual(widget)
})
it('maintains proper component structure', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
// Should have layout field containing label with color picker and text
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const label = wrapper.find('label')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorText = wrapper.find('span')
expect(layoutField.exists()).toBe(true)
expect(label.exists()).toBe(true)
expect(colorPicker.exists()).toBe(true)
expect(colorText.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles empty color value', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.exists()).toBe(true)
})
it('handles invalid color formats gracefully', async () => {
const widget = createMockWidget('invalid-color')
const wrapper = mountComponent(widget, 'invalid-color')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#000000')
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#000000')
})
it('handles widget with no options', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,91 @@
<!-- Needs custom color picker for alpha support -->
<template>
<WidgetLayoutField :widget="widget">
<label
:class="
cn(WidgetInputBaseClass, 'flex items-center gap-2 w-full px-4 py-2')
"
>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-8 h-4 !rounded-full overflow-hidden border-none"
:pt="{
preview: '!w-full !h-full !border-none'
}"
@update:model-value="onPickerUpdate"
/>
<span class="text-xs" data-testid="widget-color-text">{{
toHexFromFormat(localValue, format)
}}</span>
</label>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed, ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
type ColorFormat,
type HSB,
isColorFormat,
toHexFromFormat
} from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
const props = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const format = computed<ColorFormat>(() => {
const optionFormat = props.widget.options?.format
return isColorFormat(optionFormat) ? optionFormat : 'hex'
})
type PickerValue = string | HSB
const localValue = ref<PickerValue>(
toHexFromFormat(
props.modelValue || '#000000',
isColorFormat(props.widget.options?.format)
? props.widget.options.format
: 'hex'
)
)
watch(
() => props.modelValue,
(newVal) => {
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
}
)
function onPickerUpdate(val: unknown) {
localValue.value = val as PickerValue
emit('update:modelValue', toHexFromFormat(val, format.value))
}
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,588 @@
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,
'Drop your file or': 'Drop your file or'
}
}
})
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)
})
it('hides control buttons in readonly mode', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile], true)
const controlButtons = wrapper.find('.absolute.top-2.right-2')
expect(controlButtons.exists()).toBe(false)
})
})
describe('Audio File Display', () => {
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('Readonly Mode', () => {
it('disables browse button in readonly mode', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const browseButton = wrapper.find('button')
expect(browseButton.element.disabled).toBe(true)
})
it('disables file input in readonly mode', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const fileInput = wrapper.find('input[type="file"]')
const inputElement = fileInput.element
if (!(inputElement instanceof HTMLInputElement)) {
throw new Error('Expected HTMLInputElement')
}
expect(inputElement.disabled).toBe(true)
})
it('disables folder button for images in readonly mode', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile], true)
const buttons = wrapper.findAll('button')
const folderButton = buttons.find((button) =>
button.element.innerHTML.includes('pi-folder')
)
if (!folderButton) {
throw new Error('Folder button not found')
}
expect(folderButton.element.disabled).toBe(true)
})
it('does not handle file changes in readonly mode', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const file = createMockFile('test.jpg', 'image/jpeg')
const fileInput = wrapper.find('input[type="file"]')
Object.defineProperty(fileInput.element, 'files', {
value: [file],
writable: false
})
await fileInput.trigger('change')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
})
describe('Edge Cases', () => {
it('handles empty file selection gracefully', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
const fileInput = wrapper.find('input[type="file"]')
Object.defineProperty(fileInput.element, 'files', {
value: [],
writable: false
})
await fileInput.trigger('change')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('handles missing file input gracefully', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null)
// Remove file input ref to simulate missing element
wrapper.vm.$refs.fileInputRef = null
// Should not throw error when method exists
const vm = wrapper.vm as any
expect(() => vm.triggerFileInput?.()).not.toThrow()
})
it('handles clearing file when no file input exists', async () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile])
// Remove file input ref to simulate missing element
wrapper.vm.$refs.fileInputRef = null
// Find button that contains the times icon
const buttons = wrapper.findAll('button')
const deleteButton = buttons.find((button) =>
button.find('i.pi-times').exists()
)
if (!deleteButton) {
throw new Error('Delete button with times icon not found')
}
// Should not throw error
await expect(deleteButton.trigger('click')).resolves.not.toThrow()
})
it('cleans up object URLs on unmount', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile])
wrapper.unmount()
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(mockObjectURL)
})
})
})

View File

@@ -0,0 +1,328 @@
<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="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ 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"
v-bind="transformCompatProps"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Image preview -->
<!-- TODO: change hardcoded colors when design system incorporated -->
<div class="relative group">
<img :src="imageUrl" :alt="selectedFile?.name" class="w-full h-auto" />
<!-- Darkening overlay on hover -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 pointer-events-none"
/>
<!-- Control buttons in top right on hover -->
<div
v-if="!readonly"
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<!-- Edit button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="handleEdit"
>
<i class="pi pi-pencil text-white text-xs"></i>
</button>
<!-- Delete button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="clearFile"
>
<i class="pi pi-times text-white text-xs"></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="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ 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"
v-bind="transformCompatProps"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Audio player -->
<div class="relative group px-2">
<div
class="bg-[#1a1b1e] rounded-lg p-4 flex items-center gap-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="text-sm font-medium mb-1">{{ selectedFile?.name }}</div>
<div class="text-xs opacity-60">
{{
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
}}
</div>
</div>
<!-- Control buttons -->
<div v-if="!readonly" class="flex gap-1">
<!-- Delete button -->
<button
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
@click="clearFile"
>
<i class="pi pi-times text-white text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Show normal file upload UI when no image or audio is loaded -->
<div
v-else
class="flex flex-col gap-1 w-full border border-solid p-1 rounded-lg"
:style="{ borderColor: '#262729' }"
>
<div
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
:style="{ borderColor: '#262729' }"
>
<div class="flex flex-col items-center gap-2 w-full py-4">
<span class="text-xs opacity-60"> {{ $t('Drop your file or') }} </span>
<div>
<Button
label="Browse Files"
size="small"
severity="secondary"
class="text-xs"
:disabled="readonly"
@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"
:multiple="false"
:disabled="readonly"
@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,
readonly = false
} = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
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 (!readonly && target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]
// Use the composable's onChange handler with an array
onChange([file])
// Reset input to allow selecting same file again
target.value = ''
}
}
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
})
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>

View File

@@ -0,0 +1,443 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import type { GalleriaProps } from 'primevue/galleria'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetGalleria, {
type GalleryImage,
type GalleryValue
} from './WidgetGalleria.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
'Gallery image': 'Gallery image'
}
}
})
// Test data constants for better test isolation
const TEST_IMAGES_SMALL: readonly string[] = Object.freeze([
'https://example.com/image0.jpg',
'https://example.com/image1.jpg',
'https://example.com/image2.jpg'
])
const TEST_IMAGES_SINGLE: readonly string[] = Object.freeze([
'https://example.com/single.jpg'
])
const TEST_IMAGE_OBJECTS: readonly GalleryImage[] = Object.freeze([
{
itemImageSrc: 'https://example.com/image0.jpg',
thumbnailImageSrc: 'https://example.com/thumb0.jpg',
alt: 'Test image 0'
},
{
itemImageSrc: 'https://example.com/image1.jpg',
thumbnailImageSrc: 'https://example.com/thumb1.jpg',
alt: 'Test image 1'
}
])
// Helper functions outside describe blocks for better clarity
function createMockWidget(
value: GalleryValue = [],
options: Partial<GalleriaProps> = {}
): SimplifiedWidget<GalleryValue> {
return {
name: 'test_galleria',
type: 'array',
value,
options
}
}
function mountComponent(
widget: SimplifiedWidget<GalleryValue>,
modelValue: GalleryValue,
readonly = false
) {
return mount(WidgetGalleria, {
global: {
plugins: [PrimeVue, i18n],
components: { Galleria }
},
props: {
widget,
readonly,
modelValue
}
})
}
function createImageStrings(count: number): string[] {
return Array.from(
{ length: count },
(_, i) => `https://example.com/image${i}.jpg`
)
}
// Factory function that takes images, creates widget internally, returns wrapper
function createGalleriaWrapper(
images: GalleryValue,
options: Partial<GalleriaProps> = {},
readonly = false
) {
const widget = createMockWidget(images, options)
return mountComponent(widget, images, readonly)
}
describe('WidgetGalleria Image Display', () => {
// Group tests using the readonly constants where appropriate
describe('Component Rendering', () => {
it('renders galleria component', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.exists()).toBe(true)
})
it('displays empty gallery when no images provided', () => {
const widget = createMockWidget([])
const wrapper = mountComponent(widget, [])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('value')).toEqual([])
})
it('handles null or undefined value gracefully', () => {
const widget = createMockWidget([])
const wrapper = mountComponent(widget, [])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('value')).toEqual([])
})
})
describe('String Array Input', () => {
it('converts string array to image objects', () => {
const widget = createMockWidget([...TEST_IMAGES_SMALL])
const wrapper = mountComponent(widget, [...TEST_IMAGES_SMALL])
const galleria = wrapper.findComponent({ name: 'Galleria' })
const value = galleria.props('value')
expect(value).toHaveLength(3)
expect(value[0]).toEqual({
itemImageSrc: 'https://example.com/image0.jpg',
thumbnailImageSrc: 'https://example.com/image0.jpg',
alt: 'Image 0'
})
})
it('handles single string image', () => {
const widget = createMockWidget([...TEST_IMAGES_SINGLE])
const wrapper = mountComponent(widget, [...TEST_IMAGES_SINGLE])
const galleria = wrapper.findComponent({ name: 'Galleria' })
const value = galleria.props('value')
expect(value).toHaveLength(1)
expect(value[0]).toEqual({
itemImageSrc: 'https://example.com/single.jpg',
thumbnailImageSrc: 'https://example.com/single.jpg',
alt: 'Image 0'
})
})
})
describe('Object Array Input', () => {
it('preserves image objects as-is', () => {
const widget = createMockWidget([...TEST_IMAGE_OBJECTS])
const wrapper = mountComponent(widget, [...TEST_IMAGE_OBJECTS])
const galleria = wrapper.findComponent({ name: 'Galleria' })
const value = galleria.props('value')
expect(value).toEqual([...TEST_IMAGE_OBJECTS])
})
it('handles mixed object properties', () => {
const images: GalleryImage[] = [
{ src: 'https://example.com/image1.jpg', alt: 'First' },
{ itemImageSrc: 'https://example.com/image2.jpg' },
{ thumbnailImageSrc: 'https://example.com/thumb3.jpg' }
]
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
const value = galleria.props('value')
expect(value).toEqual(images)
})
})
describe('Thumbnail Display', () => {
it('shows thumbnails when multiple images present', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showThumbnails')).toBe(true)
})
it('hides thumbnails for single image', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showThumbnails')).toBe(false)
})
it('respects widget option to hide thumbnails', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
showThumbnails: false
})
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showThumbnails')).toBe(false)
})
it('shows thumbnails when explicitly enabled for multiple images', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
showThumbnails: true
})
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showThumbnails')).toBe(true)
})
})
describe('Navigation Buttons', () => {
it('shows navigation buttons when multiple images present', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showItemNavigators')).toBe(true)
})
it('hides navigation buttons for single image', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showItemNavigators')).toBe(false)
})
it('respects widget option to hide navigation buttons', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images, { showItemNavigators: false })
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showItemNavigators')).toBe(false)
})
it('shows navigation buttons when explicitly enabled for multiple images', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images, { showItemNavigators: true })
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('showItemNavigators')).toBe(true)
})
})
describe('Readonly Mode', () => {
it('passes readonly state to galleria when readonly', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images, true)
// Galleria component should receive readonly state (though it may not support disabled)
expect(wrapper.props('readonly')).toBe(true)
})
it('passes readonly state to galleria when not readonly', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images, false)
expect(wrapper.props('readonly')).toBe(false)
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const images = createImageStrings(2)
const widget = createMockWidget(images, {
circular: true,
autoPlay: true,
transitionInterval: 3000
})
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('circular')).toBe(true)
expect(galleria.props('autoPlay')).toBe(true)
expect(galleria.props('transitionInterval')).toBe(3000)
})
it('applies custom styling props', () => {
const images = createImageStrings(2)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
// Check that galleria has styling attributes rather than specific classes
expect(galleria.attributes('class')).toBeDefined()
})
})
describe('Active Index Management', () => {
it('initializes with zero active index', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('activeIndex')).toBe(0)
})
it('can update active index', async () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
await galleria.vm.$emit('update:activeIndex', 2)
// Check that the internal activeIndex ref was updated
const vm = wrapper.vm as any
expect(vm.activeIndex).toBe(2)
})
})
describe('Image Template Rendering', () => {
it('renders item template with correct image source priorities', () => {
const images: GalleryImage[] = [
{
itemImageSrc: 'https://example.com/item.jpg',
src: 'https://example.com/fallback.jpg'
},
{ src: 'https://example.com/only-src.jpg' }
]
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
// The template logic should prioritize itemImageSrc > src > fallback to the item itself
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.exists()).toBe(true)
})
it('renders thumbnail template with correct image source priorities', () => {
const images: GalleryImage[] = [
{
thumbnailImageSrc: 'https://example.com/thumb.jpg',
src: 'https://example.com/fallback.jpg'
},
{ src: 'https://example.com/only-src.jpg' }
]
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
// The template logic should prioritize thumbnailImageSrc > src > fallback to the item itself
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles empty array gracefully', () => {
const widget = createMockWidget([])
const wrapper = mountComponent(widget, [])
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('value')).toEqual([])
expect(galleria.props('showThumbnails')).toBe(false)
expect(galleria.props('showItemNavigators')).toBe(false)
})
it('handles malformed image objects', () => {
const malformedImages = [
{}, // Empty object
{ randomProp: 'value' }, // Object without expected image properties
null, // Null value
undefined // Undefined value
]
const widget = createMockWidget(malformedImages as string[])
const wrapper = mountComponent(widget, malformedImages as string[])
const galleria = wrapper.findComponent({ name: 'Galleria' })
// Null/undefined should be filtered out, leaving only the objects
const expectedValue = [{}, { randomProp: 'value' }]
expect(galleria.props('value')).toEqual(expectedValue)
})
it('handles very large image arrays', () => {
const largeImageArray = createImageStrings(100)
const widget = createMockWidget(largeImageArray)
const wrapper = mountComponent(widget, largeImageArray)
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('value')).toHaveLength(100)
expect(galleria.props('showThumbnails')).toBe(true)
expect(galleria.props('showItemNavigators')).toBe(true)
})
it('handles mixed string and object arrays gracefully', () => {
// This is technically invalid input, but the component should handle it
const mixedArray = [
'https://example.com/string.jpg',
{ itemImageSrc: 'https://example.com/object.jpg' },
'https://example.com/another-string.jpg'
]
const widget = createMockWidget(mixedArray as string[])
// The component expects consistent typing, but let's test it handles mixed input
expect(() => mountComponent(widget, mixedArray as string[])).not.toThrow()
})
it('handles invalid URL strings', () => {
const invalidUrls = ['not-a-url', '', ' ', 'http://', 'ftp://invalid']
const widget = createMockWidget(invalidUrls)
const wrapper = mountComponent(widget, invalidUrls)
const galleria = wrapper.findComponent({ name: 'Galleria' })
expect(galleria.props('value')).toHaveLength(5)
})
})
describe('Styling and Layout', () => {
it('applies max-width constraint', () => {
const images = createImageStrings(2)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
// Check that component has styling applied rather than specific classes
expect(galleria.attributes('class')).toBeDefined()
})
it('applies passthrough props for thumbnails', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images)
const galleria = wrapper.findComponent({ name: 'Galleria' })
const pt = galleria.props('pt')
expect(pt).toBeDefined()
expect(pt.thumbnails).toBeDefined()
expect(pt.thumbnailContent).toBeDefined()
expect(pt.thumbnailPrevButton).toBeDefined()
expect(pt.thumbnailNextButton).toBeDefined()
})
})
})

View File

@@ -0,0 +1,133 @@
<template>
<div class="flex flex-col gap-1">
<Galleria
v-model:active-index="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:show-thumbnails="showThumbnails"
:show-item-navigators="showNavButtons"
class="max-w-full"
:pt="{
thumbnails: {
class: 'overflow-hidden'
},
thumbnailContent: {
class: 'py-4 px-2'
},
thumbnailPrevButton: {
class: 'm-0'
},
thumbnailNextButton: {
class: 'm-0'
}
}"
>
<template #item="{ item }">
<img
:src="item?.itemImageSrc || item?.src || ''"
:alt="
item?.alt ||
`${t('g.galleryImage')} ${activeIndex + 1} of ${galleryImages.length}`
"
class="w-full h-auto max-h-64 object-contain"
/>
</template>
<template #thumbnail="{ item }">
<div class="p-1 w-full h-full">
<img
:src="item?.thumbnailImageSrc || item?.src || ''"
:alt="
item?.alt ||
`${t('g.galleryThumbnail')} ${galleryImages.findIndex((img) => img === item) + 1} of ${galleryImages.length}`
"
class="w-full h-full object-cover rounded-lg"
/>
</div>
</template>
</Galleria>
</div>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
GALLERIA_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
export interface GalleryImage {
itemImageSrc?: string
thumbnailImageSrc?: string
src?: string
alt?: string
}
export type GalleryValue = string[] | GalleryImage[]
const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)
const { t } = useI18n()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
)
const galleryImages = computed(() => {
if (!value.value || !Array.isArray(value.value)) return []
return value.value
.filter((item) => item !== null && item !== undefined) // Filter out null/undefined
.map((item, index) => {
if (typeof item === 'string') {
return {
itemImageSrc: item,
thumbnailImageSrc: item,
alt: `Image ${index}`
}
}
return item ?? {} // Ensure we have at least an empty object
})
})
const showThumbnails = computed(() => {
return (
props.widget.options?.showThumbnails !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showItemNavigators !== false &&
galleryImages.value.length > 1
)
})
</script>
<style scoped>
/* Ensure thumbnail container doesn't overflow */
:deep(.p-galleria-thumbnails) {
overflow: hidden;
}
/* Constrain thumbnail items to prevent overlap */
:deep(.p-galleria-thumbnail-item) {
flex-shrink: 0;
}
/* Ensure thumbnail wrapper maintains aspect ratio */
:deep(.p-galleria-thumbnail) {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,337 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetImageCompare, {
type ImageCompareValue
} from './WidgetImageCompare.vue'
describe('WidgetImageCompare Display', () => {
const createMockWidget = (
value: ImageCompareValue | string,
options: SimplifiedWidget['options'] = {}
): SimplifiedWidget<ImageCompareValue | string> => ({
name: 'test_imagecompare',
type: 'object',
value,
options
})
const mountComponent = (
widget: SimplifiedWidget<ImageCompareValue | string>,
readonly = false
) => {
return mount(WidgetImageCompare, {
global: {
plugins: [PrimeVue],
components: { ImageCompare }
},
props: {
widget,
readonly
}
})
}
describe('Component Rendering', () => {
it('renders imagecompare component with proper structure and styling', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
// Component exists
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
// Renders both images with correct URLs
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// Images have proper styling classes
images.forEach((img) => {
expect(img.classes()).toContain('object-cover')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
})
})
})
describe('Object Value Input', () => {
it('handles alt text correctly - custom, default, and empty', () => {
// Test custom alt text
const customAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg',
beforeAlt: 'Original design',
afterAlt: 'Updated design'
}
const customWrapper = mountComponent(createMockWidget(customAltValue))
const customImages = customWrapper.findAll('img')
expect(customImages[0].attributes('alt')).toBe('Original design')
expect(customImages[1].attributes('alt')).toBe('Updated design')
// Test default alt text
const defaultAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
const defaultImages = defaultWrapper.findAll('img')
expect(defaultImages[0].attributes('alt')).toBe('Before image')
expect(defaultImages[1].attributes('alt')).toBe('After image')
// Test empty string alt text (falls back to default)
const emptyAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg',
beforeAlt: '',
afterAlt: ''
}
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
const emptyImages = emptyWrapper.findAll('img')
expect(emptyImages[0].attributes('alt')).toBe('Before image')
expect(emptyImages[1].attributes('alt')).toBe('After image')
})
it('handles missing and partial image URLs gracefully', () => {
// Missing URLs
const missingValue: ImageCompareValue = { before: '', after: '' }
const missingWrapper = mountComponent(createMockWidget(missingValue))
const missingImages = missingWrapper.findAll('img')
expect(missingImages[0].attributes('src')).toBe('')
expect(missingImages[1].attributes('src')).toBe('')
// Partial URLs
const partialValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: ''
}
const partialWrapper = mountComponent(createMockWidget(partialValue))
const partialImages = partialWrapper.findAll('img')
expect(partialImages[0].attributes('src')).toBe(
'https://example.com/before.jpg'
)
expect(partialImages[1].attributes('src')).toBe('')
})
})
describe('String Value Input', () => {
it('handles string value as before image only', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
expect(images[1].attributes('src')).toBe('')
})
it('uses default alt text for string values', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
})
describe('Widget Options Handling', () => {
it('passes through accessibility options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
tabindex: 1,
ariaLabel: 'Compare images',
ariaLabelledby: 'compare-label'
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(1)
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
})
it('uses default tabindex when not provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(0)
})
it('passes through PrimeVue specific options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
unstyled: true,
pt: { root: { class: 'custom-class' } },
ptOptions: { mergeSections: true }
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('unstyled')).toBe(true)
expect(imageCompare.props('pt')).toEqual({
root: { class: 'custom-class' }
})
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
})
})
describe('Readonly Mode', () => {
it('renders normally in readonly mode (no interaction restrictions)', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget, true)
// ImageCompare is display-only, readonly doesn't affect rendering
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('handles null or undefined widget value', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
it('handles empty object value', () => {
const value: ImageCompareValue = {} as ImageCompareValue
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles malformed object value', () => {
const value = { randomProp: 'test', before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles special content - long URLs, special characters, and long alt text', () => {
// Test very long URLs
const longUrl = 'https://example.com/' + 'a'.repeat(1000) + '.jpg'
const longUrlValue: ImageCompareValue = {
before: longUrl,
after: longUrl
}
const longUrlWrapper = mountComponent(createMockWidget(longUrlValue))
const longUrlImages = longUrlWrapper.findAll('img')
expect(longUrlImages[0].attributes('src')).toBe(longUrl)
expect(longUrlImages[1].attributes('src')).toBe(longUrl)
// Test special characters in URLs
const specialUrl =
'https://example.com/path with spaces & symbols!@#$.jpg'
const specialUrlValue: ImageCompareValue = {
before: specialUrl,
after: specialUrl
}
const specialUrlWrapper = mountComponent(
createMockWidget(specialUrlValue)
)
const specialUrlImages = specialUrlWrapper.findAll('img')
expect(specialUrlImages[0].attributes('src')).toBe(specialUrl)
expect(specialUrlImages[1].attributes('src')).toBe(specialUrl)
// Test very long alt text
const longAlt =
'Very long alt text that exceeds normal length: ' +
'description '.repeat(50)
const longAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg',
beforeAlt: longAlt,
afterAlt: longAlt
}
const longAltWrapper = mountComponent(createMockWidget(longAltValue))
const longAltImages = longAltWrapper.findAll('img')
expect(longAltImages[0].attributes('alt')).toBe(longAlt)
expect(longAltImages[1].attributes('alt')).toBe(longAlt)
})
})
describe('Template Structure', () => {
it('correctly assigns images to left and right template slots', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
// First image (before) should be in left template slot
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
// Second image (after) should be in right template slot
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
})
})
describe('Integration', () => {
it('works with various URL types - data URLs and blob URLs', () => {
// Test data URLs
const dataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
const dataUrlValue: ImageCompareValue = {
before: dataUrl,
after: dataUrl
}
const dataUrlWrapper = mountComponent(createMockWidget(dataUrlValue))
const dataUrlImages = dataUrlWrapper.findAll('img')
expect(dataUrlImages[0].attributes('src')).toBe(dataUrl)
expect(dataUrlImages[1].attributes('src')).toBe(dataUrl)
// Test blob URLs
const blobUrl =
'blob:http://example.com/12345678-1234-1234-1234-123456789012'
const blobUrlValue: ImageCompareValue = {
before: blobUrl,
after: blobUrl
}
const blobUrlWrapper = mountComponent(createMockWidget(blobUrlValue))
const blobUrlImages = blobUrlWrapper.findAll('img')
expect(blobUrlImages[0].attributes('src')).toBe(blobUrl)
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
})
})
})

View File

@@ -0,0 +1,70 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="w-full h-full object-cover"
/>
</template>
<template #right>
<img
:src="afterImage"
:alt="afterAlt"
class="w-full h-full object-cover"
/>
</template>
</ImageCompare>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
export interface ImageCompareValue {
before: string
after: string
beforeAlt?: string
afterAlt?: string
initialPosition?: number
}
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
readonly?: boolean
}>()
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''
})
const afterImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? '' : value?.after || ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})
</script>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
</script>
<template>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-model="modelValue"
:widget="widget"
:readonly="readonly"
v-bind="$attrs"
/>
</template>

View File

@@ -0,0 +1,353 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
function createMockWidget(
value: number = 0,
type: 'int' | 'float' = 'int',
options: SimplifiedWidget['options'] = {},
callback?: (value: number) => void
): SimplifiedWidget<number> {
return {
name: 'test_input_number',
type,
value,
options,
callback
}
}
function mountComponent(
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) {
return mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
props: {
widget,
modelValue,
readonly
}
})
}
function getNumberInput(wrapper: ReturnType<typeof mount>) {
const input = wrapper.get<HTMLInputElement>('input[inputmode="numeric"]')
return input.element
}
describe('WidgetInputNumberInput Value Binding', () => {
it('displays initial value in input field', () => {
const widget = createMockWidget(42, 'int')
const wrapper = mountComponent(widget, 42)
const input = getNumberInput(wrapper)
expect(input.value).toBe('42')
})
it('emits update:modelValue when value changes', async () => {
const widget = createMockWidget(10, 'int')
const wrapper = mountComponent(widget, 10)
const inputNumber = wrapper.findComponent(InputNumber)
await inputNumber.vm.$emit('update:modelValue', 20)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(20)
})
it('handles negative values', () => {
const widget = createMockWidget(-5, 'int')
const wrapper = mountComponent(widget, -5)
const input = getNumberInput(wrapper)
expect(input.value).toBe('-5')
})
it('handles decimal values for float type', () => {
const widget = createMockWidget(3.14, 'float')
const wrapper = mountComponent(widget, 3.14)
const input = getNumberInput(wrapper)
expect(input.value).toBe('3.14')
})
})
describe('WidgetInputNumberInput Component Rendering', () => {
it('renders InputNumber component with show-buttons', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.exists()).toBe(true)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('disables input when readonly', () => {
const widget = createMockWidget(5, 'int', {}, undefined)
const wrapper = mountComponent(widget, 5, true)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
})
it('sets button layout to horizontal', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('buttonLayout')).toBe('horizontal')
})
it('sets size to small', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('size')).toBe('small')
})
})
describe('WidgetInputNumberInput Step Value', () => {
it('defaults to 0 for unrestricted stepping', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0)
})
it('uses step2 value when provided', () => {
const widget = createMockWidget(5, 'int', { step2: 0.5 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.5)
})
it('calculates step from precision for precision 0', () => {
const widget = createMockWidget(5, 'int', { precision: 0 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(1)
})
it('calculates step from precision for precision 1', () => {
const widget = createMockWidget(5, 'float', { precision: 1 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.1)
})
it('calculates step from precision for precision 2', () => {
const widget = createMockWidget(5, 'float', { precision: 2 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.01)
})
})
describe('WidgetInputNumberInput Grouping Behavior', () => {
it('displays numbers without commas by default for int widgets', () => {
const widget = createMockWidget(1000, 'int')
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000')
expect(input.value).not.toContain(',')
})
it('displays numbers without commas by default for float widgets', () => {
const widget = createMockWidget(1000.5, 'float')
const wrapper = mountComponent(widget, 1000.5)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000.5')
expect(input.value).not.toContain(',')
})
it('displays numbers with commas when grouping enabled', () => {
const widget = createMockWidget(1000, 'int', { useGrouping: true })
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1,000')
expect(input.value).toContain(',')
})
it('displays numbers without commas when grouping explicitly disabled', () => {
const widget = createMockWidget(1000, 'int', { useGrouping: false })
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000')
expect(input.value).not.toContain(',')
})
it('displays numbers without commas when useGrouping option is undefined', () => {
const widget = createMockWidget(1000, 'int', { useGrouping: undefined })
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000')
expect(input.value).not.toContain(',')
})
})
describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER // 9,007,199,254,740,991
const UNSAFE_LARGE_INTEGER = 18446744073709552000 // Example seed value that exceeds safe range
it('shows buttons for safe integer values', () => {
const widget = createMockWidget(1000, 'int')
const wrapper = mountComponent(widget, 1000)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('shows buttons for values at safe integer limit', () => {
const widget = createMockWidget(SAFE_INTEGER_MAX, 'int')
const wrapper = mountComponent(widget, SAFE_INTEGER_MAX)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('hides buttons for unsafe large integer values', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
})
it('hides buttons for unsafe negative integer values', () => {
const unsafeNegative = -UNSAFE_LARGE_INTEGER
const widget = createMockWidget(unsafeNegative, 'int')
const wrapper = mountComponent(widget, unsafeNegative)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
})
it('shows tooltip for disabled buttons due to precision limits', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
// Check that tooltip wrapper div exists
const tooltipDiv = wrapper.find('div[v-tooltip]')
expect(tooltipDiv.exists()).toBe(true)
})
it('does not show tooltip for safe integer values', () => {
const widget = createMockWidget(1000, 'int')
const wrapper = mountComponent(widget, 1000)
// For safe values, tooltip should not be set (computed returns null)
const tooltipDiv = wrapper.find('div')
expect(tooltipDiv.attributes('v-tooltip')).toBeUndefined()
})
it('handles edge case of zero value', () => {
const widget = createMockWidget(0, 'int')
const wrapper = mountComponent(widget, 0)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('correctly identifies safe vs unsafe integers using Number.isSafeInteger', () => {
// Test the JavaScript behavior our component relies on
expect(Number.isSafeInteger(SAFE_INTEGER_MAX)).toBe(true)
expect(Number.isSafeInteger(SAFE_INTEGER_MAX + 1)).toBe(false)
expect(Number.isSafeInteger(UNSAFE_LARGE_INTEGER)).toBe(false)
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX)).toBe(true)
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX - 1)).toBe(false)
})
it('maintains readonly behavior even for unsafe values', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER, true)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
expect(inputNumber.props('showButtons')).toBe(false) // Still hidden due to unsafe value
})
it('handles floating point values correctly', () => {
const safeFloat = 1000.5
const widget = createMockWidget(safeFloat, 'float')
const wrapper = mountComponent(widget, safeFloat)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('hides buttons for unsafe floating point values', () => {
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
const widget = createMockWidget(unsafeFloat, 'float')
const wrapper = mountComponent(widget, unsafeFloat)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
})
})
describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
it('handles null/undefined model values gracefully', () => {
const widget = createMockWidget(0, 'int')
// Mount with undefined as modelValue
const wrapper = mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
props: {
widget,
modelValue: undefined as any
}
})
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
})
it('handles NaN values gracefully', () => {
const widget = createMockWidget(NaN, 'int')
const wrapper = mountComponent(widget, NaN)
const inputNumber = wrapper.findComponent(InputNumber)
// NaN is not a safe integer, so buttons should be hidden
expect(inputNumber.props('showButtons')).toBe(false)
})
it('handles Infinity values', () => {
const widget = createMockWidget(Infinity, 'int')
const wrapper = mountComponent(widget, Infinity)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
})
it('handles negative Infinity values', () => {
const widget = createMockWidget(-Infinity, 'int')
const wrapper = mountComponent(widget, -Infinity)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
})
})

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
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 {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const { localValue, onChange } = useNumberWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
})
// Disable grouping separators by default unless explicitly enabled by the node author
const useGrouping = computed(() => {
return props.widget.options?.useGrouping === true
})
// Check if increment/decrement buttons should be disabled due to precision limits
const buttonsDisabled = computed(() => {
const currentValue = localValue.value || 0
return !Number.isSafeInteger(currentValue)
})
// Tooltip message for disabled buttons
const buttonTooltip = computed(() => {
if (props.readonly) return null
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
}
return null
})
</script>
<template>
<WidgetLayoutField :widget>
<div v-tooltip="buttonTooltip">
<InputNumber
v-model="localValue"
v-bind="filteredProps"
:show-buttons="!buttonsDisabled"
button-layout="horizontal"
size="small"
:disabled="readonly"
:step="stepValue"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:pt="{
incrementButton:
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
decrementButton:
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
}"
@update:model-value="onChange"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
</div>
</WidgetLayoutField>
</template>
<style scoped>
:deep(.p-inputnumber-input) {
background-color: transparent;
border: 1px solid color-mix(in oklab, #d4d4d8 10%, transparent);
border-top: transparent;
border-bottom: transparent;
height: 1.625rem;
margin: 1px 0;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,175 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import Slider from '@/components/ui/slider/Slider.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
function createMockWidget(
value: number = 5,
options: SimplifiedWidget['options'] = {},
callback?: (value: number) => void
): SimplifiedWidget<number> {
return {
name: 'test_slider',
type: 'float',
value,
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
callback
}
}
function mountComponent(
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) {
return mount(WidgetInputNumberSlider, {
global: {
plugins: [PrimeVue],
components: { InputNumber, Slider }
},
props: {
widget,
modelValue,
readonly
}
})
}
function getNumberInput(wrapper: ReturnType<typeof mount>) {
const input = wrapper.find('input[inputmode="numeric"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error(
'Number input element not found or is not an HTMLInputElement'
)
}
return input.element
}
describe('WidgetInputNumberSlider Value Binding', () => {
describe('Props and Values', () => {
it('passes modelValue to slider component', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('modelValue')).toEqual([5])
})
it('handles different initial values', () => {
const widget1 = createMockWidget(5)
const wrapper1 = mountComponent(widget1, 5)
const widget2 = createMockWidget(10)
const wrapper2 = mountComponent(widget2, 10)
const slider1 = wrapper1.findComponent({ name: 'Slider' })
expect(slider1.props('modelValue')).toEqual([5])
const slider2 = wrapper2.findComponent({ name: 'Slider' })
expect(slider2.props('modelValue')).toEqual([10])
})
})
describe('Component Rendering', () => {
it('renders slider component', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
expect(wrapper.findComponent({ name: 'Slider' }).exists()).toBe(true)
})
it('renders input field', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
console.log(wrapper.html())
expect(wrapper.find('input[inputmode="numeric"]').exists()).toBe(true)
})
it('displays initial value in input field', () => {
const widget = createMockWidget(42)
const wrapper = mountComponent(widget, 42)
const input = getNumberInput(wrapper)
expect(input.value).toBe('42')
})
it('disables components in readonly mode', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5, true)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('disabled')).toBe(true)
const input = getNumberInput(wrapper)
expect(input.disabled).toBe(true)
})
})
describe('Widget Options', () => {
it('passes widget options to PrimeVue components', () => {
const widget = createMockWidget(5, { min: -10, max: 50 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('min')).toBe(-10)
expect(slider.props('max')).toBe(50)
})
it('handles negative value ranges', () => {
const widget = createMockWidget(0, { min: -100, max: 100 })
const wrapper = mountComponent(widget, 0)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('min')).toBe(-100)
expect(slider.props('max')).toBe(100)
})
describe('Step Size', () => {
it('should default to 1', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(1)
})
it('should get the step2 value if present', () => {
const widget = createMockWidget(5, { step2: 0.01 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(0.01)
})
it('should be 1 for precision 0', () => {
const widget = createMockWidget(5, { precision: 0 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(1)
})
it('should be .1 for precision 1', () => {
const widget = createMockWidget(5, { precision: 1 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(0.1)
})
it('should be .00001 for precision 5', () => {
const widget = createMockWidget(5, { precision: 5 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(0.00001)
})
})
})
})

View File

@@ -0,0 +1,107 @@
<template>
<WidgetLayoutField :widget="widget">
<div
:class="
cn(WidgetInputBaseClass, 'flex items-center gap-2 w-full pl-4 pr-2')
"
>
<Slider
:model-value="[localValue]"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow text-xs"
:step="stepValue"
@update:model-value="updateLocalValue"
/>
<InputNumber
:key="timesEmptied"
:model-value="localValue"
v-bind="filteredProps"
:disabled="readonly"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
size="small"
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
class="w-16"
@update:model-value="handleNumberInputUpdate"
/>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
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 {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget, modelValue, readonly } = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useNumberWidgetValue(widget, modelValue, emit)
const timesEmptied = ref(0)
const updateLocalValue = (newValue: number[] | undefined): void => {
onChange(newValue ?? [localValue.value])
}
const handleNumberInputUpdate = (newValue: number | undefined) => {
if (newValue) {
updateLocalValue([newValue])
return
}
timesEmptied.value += 1
}
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (widget.options?.step2 !== undefined) {
return widget.options.step2
}
// Otherwise, derive from precision
if (precision.value === undefined) {
return undefined
}
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return 1 / Math.pow(10, precision.value)
})
</script>

View File

@@ -0,0 +1,193 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import type { InputTextProps } from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputText from './WidgetInputText.vue'
describe('WidgetInputText Value Binding', () => {
const createMockWidget = (
value: string = 'default',
options: Partial<InputTextProps> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_input',
type: 'string',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
) => {
return mount(WidgetInputText, {
global: {
plugins: [PrimeVue],
components: { InputText, Textarea }
},
props: {
widget,
modelValue,
readonly
}
})
}
const setInputValueAndTrigger = async (
wrapper: ReturnType<typeof mount>,
value: string,
trigger: 'blur' | 'keydown.enter' = 'blur'
) => {
const input = wrapper.find('input[type="text"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error('Input element not found or is not an HTMLInputElement')
}
await input.setValue(value)
await input.trigger(trigger)
return input
}
describe('Vue Event Emission', () => {
it('emits Vue event when input value changes on blur', async () => {
const widget = createMockWidget('hello')
const wrapper = mountComponent(widget, 'hello')
await setInputValueAndTrigger(wrapper, 'world', 'blur')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('world')
})
it('emits Vue event when enter key is pressed', async () => {
const widget = createMockWidget('initial')
const wrapper = mountComponent(widget, 'initial')
await setInputValueAndTrigger(wrapper, 'new value', 'keydown.enter')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('new value')
})
it('handles empty string values', async () => {
const widget = createMockWidget('something')
const wrapper = mountComponent(widget, 'something')
await setInputValueAndTrigger(wrapper, '')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('')
})
it('handles special characters correctly', async () => {
const widget = createMockWidget('normal')
const wrapper = mountComponent(widget, 'normal')
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
await setInputValueAndTrigger(wrapper, specialText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(specialText)
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('test', {}, undefined)
const wrapper = mountComponent(widget, 'test')
await setInputValueAndTrigger(wrapper, 'new value')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('new value')
})
})
describe('User Interactions', () => {
it('emits update:modelValue on blur', async () => {
const widget = createMockWidget('original')
const wrapper = mountComponent(widget, 'original')
await setInputValueAndTrigger(wrapper, 'updated')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('updated')
})
it('emits update:modelValue on enter key', async () => {
const widget = createMockWidget('start')
const wrapper = mountComponent(widget, 'start')
await setInputValueAndTrigger(wrapper, 'finish', 'keydown.enter')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('finish')
})
})
describe('Readonly Mode', () => {
it('disables input when readonly', () => {
const widget = createMockWidget('readonly test')
const wrapper = mountComponent(widget, 'readonly test', true)
const input = wrapper.find('input[type="text"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error('Input element not found or is not an HTMLInputElement')
}
expect(input.element.disabled).toBe(true)
})
})
describe('Component Rendering', () => {
it('always renders InputText component', () => {
const widget = createMockWidget('test value')
const wrapper = mountComponent(widget, 'test value')
// WidgetInputText always uses InputText, not Textarea
const input = wrapper.find('input[type="text"]')
expect(input.exists()).toBe(true)
// Should not render textarea (that's handled by WidgetTextarea component)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(false)
})
})
describe('Edge Cases', () => {
it('handles very long strings', async () => {
const widget = createMockWidget('short')
const wrapper = mountComponent(widget, 'short')
const longString = 'a'.repeat(10000)
await setInputValueAndTrigger(wrapper, longString)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(longString)
})
it('handles unicode characters', async () => {
const widget = createMockWidget('ascii')
const wrapper = mountComponent(widget, 'ascii')
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
await setInputValueAndTrigger(wrapper, unicodeText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(unicodeText)
})
})
})

View File

@@ -0,0 +1,49 @@
<template>
<WidgetLayoutField :widget="widget">
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
size="small"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
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 {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
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 filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,432 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetMarkdown from './WidgetMarkdown.vue'
// Mock the markdown renderer utility
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((markdown: string) => {
// Simple mock that converts some markdown to HTML
return markdown
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^# (.*?)$/gm, '<h1>$1</h1>')
.replace(/^## (.*?)$/gm, '<h2>$1</h2>')
.replace(/\n/g, '<br>')
})
}))
describe('WidgetMarkdown Dual Mode Display', () => {
const createMockWidget = (
value: string = '# Default Heading\nSome **bold** text.',
options: Record<string, unknown> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_markdown',
type: 'string',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
) => {
return mount(WidgetMarkdown, {
global: {
plugins: [PrimeVue],
components: { Textarea }
},
props: {
widget,
modelValue,
readonly
}
})
}
const clickToEdit = async (wrapper: ReturnType<typeof mount>) => {
const container = wrapper.find('.widget-markdown')
await container.trigger('click')
await nextTick()
return container
}
const blurTextarea = async (wrapper: ReturnType<typeof mount>) => {
const textarea = wrapper.find('textarea')
if (textarea.exists()) {
await textarea.trigger('blur')
await nextTick()
}
return textarea
}
describe('Display Mode', () => {
it('renders markdown content as HTML in display mode', () => {
const markdown = '# Heading\nSome **bold** and *italic* text.'
const widget = createMockWidget(markdown)
const wrapper = mountComponent(widget, markdown)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
expect(displayDiv.html()).toContain('<h1>Heading</h1>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
expect(displayDiv.html()).toContain('<em>italic</em>')
})
it('starts in display mode by default', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('applies styling classes to display container', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.classes()).toContain('text-xs')
expect(displayDiv.classes()).toContain('min-h-[60px]')
expect(displayDiv.classes()).toContain('rounded-lg')
expect(displayDiv.classes()).toContain('px-4')
expect(displayDiv.classes()).toContain('py-2')
expect(displayDiv.classes()).toContain('overflow-y-auto')
})
it('handles empty markdown content', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
expect(displayDiv.text()).toBe('')
})
})
describe('Edit Mode Toggle', () => {
it('switches to edit mode when clicked', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
await clickToEdit(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(false)
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('does not switch to edit mode when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
await clickToEdit(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('does not switch to edit mode when already editing', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
// First click to enter edit mode
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
// Second click should not have any effect
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('switches back to display mode on textarea blur', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
await blurTextarea(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
})
describe('Edit Mode', () => {
it('displays textarea with current value when editing', async () => {
const markdown = '# Original Content'
const widget = createMockWidget(markdown)
const wrapper = mountComponent(widget, markdown)
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
expect(textarea.element.value).toBe('# Original Content')
})
it('applies styling and configuration to textarea', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.findComponent({ name: 'Textarea' })
expect(textarea.props('size')).toBe('small')
// Check rows attribute in the DOM instead of props
const textareaElement = wrapper.find('textarea')
expect(textareaElement.attributes('rows')).toBe('6')
expect(textarea.classes()).toContain('text-xs')
expect(textarea.classes()).toContain('w-full')
})
it('disables textarea when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
// Readonly should prevent entering edit mode
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('stops click and keydown event propagation in edit mode', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const clickSpy = vi.fn()
const keydownSpy = vi.fn()
wrapper.element.addEventListener('click', clickSpy)
wrapper.element.addEventListener('keydown', keydownSpy)
await textarea.trigger('click')
await textarea.trigger('keydown', { key: 'Enter' })
// Events should be stopped from propagating
expect(clickSpy).not.toHaveBeenCalled()
expect(keydownSpy).not.toHaveBeenCalled()
})
})
describe('Value Updates', () => {
it('emits update:modelValue when textarea content changes', async () => {
const widget = createMockWidget('# Original')
const wrapper = mountComponent(widget, '# Original')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Updated Content')
await textarea.trigger('input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content'])
})
it('renders updated HTML after value change and blur', async () => {
const widget = createMockWidget('# Original')
const wrapper = mountComponent(widget, '# Original')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('## New Heading\nWith **bold** text')
await textarea.trigger('input')
await blurTextarea(wrapper)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.html()).toContain('<h2>New Heading</h2>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
})
it('emits update:modelValue for callback handling at parent level', async () => {
const widget = createMockWidget('# Test', {})
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Changed')
await textarea.trigger('input')
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual(['# Changed'])
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('# Test', {}, undefined)
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Changed')
// Should not throw error and should still emit Vue event
await expect(textarea.trigger('input')).resolves.not.toThrow()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
})
})
describe('Complex Markdown Rendering', () => {
it('handles multiple markdown elements', () => {
const complexMarkdown = `# Main Heading
## Subheading
This paragraph has **bold** and *italic* text.
Another line with more content.`
const widget = createMockWidget(complexMarkdown)
const wrapper = mountComponent(widget, complexMarkdown)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.html()).toContain('<h1>Main Heading</h1>')
expect(displayDiv.html()).toContain('<h2>Subheading</h2>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
expect(displayDiv.html()).toContain('<em>italic</em>')
})
it('handles line breaks in markdown', () => {
const markdownWithBreaks = 'Line 1\nLine 2\nLine 3'
const widget = createMockWidget(markdownWithBreaks)
const wrapper = mountComponent(widget, markdownWithBreaks)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.html()).toContain('<br>')
})
it('handles empty or whitespace-only markdown', () => {
const whitespaceMarkdown = ' \n\n '
const widget = createMockWidget(whitespaceMarkdown)
const wrapper = mountComponent(widget, whitespaceMarkdown)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles very long markdown content', async () => {
const longMarkdown = '# Heading\n' + 'Lorem ipsum '.repeat(1000)
const widget = createMockWidget(longMarkdown)
const wrapper = mountComponent(widget, longMarkdown)
// Should render without issues
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
// Should switch to edit mode
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
expect(textarea.element.value).toBe(longMarkdown)
})
it('handles special characters in markdown', async () => {
const specialChars = '# Special: @#$%^&*()[]{}|\\:";\'<>?,./'
const widget = createMockWidget(specialChars)
const wrapper = mountComponent(widget, specialChars)
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe(specialChars)
})
it('handles unicode characters', async () => {
const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀'
const widget = createMockWidget(unicode)
const wrapper = mountComponent(widget, unicode)
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe(unicode)
await textarea.setValue(unicode + ' more unicode')
await textarea.trigger('input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode'])
})
it('handles rapid edit mode toggling', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
// Rapid toggling
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
await blurTextarea(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
})
})
describe('Styling and Layout', () => {
it('applies widget-markdown class to container', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const container = wrapper.find('.widget-markdown')
expect(container.exists()).toBe(true)
expect(container.classes()).toContain('relative')
expect(container.classes()).toContain('w-full')
expect(container.classes()).toContain('cursor-text')
})
it('applies overflow handling to display mode', () => {
const widget = createMockWidget(
'# Long Content\n' + 'Content '.repeat(100)
)
const wrapper = mountComponent(
widget,
'# Long Content\n' + 'Content '.repeat(100)
)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.classes()).toContain('overflow-y-auto')
})
})
describe('Focus Management', () => {
it('creates textarea reference when entering edit mode', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const vm = wrapper.vm as InstanceType<typeof WidgetMarkdown>
// Test that the component creates a textarea reference when entering edit mode
// @ts-expect-error - isEditing is not exposed
expect(vm.isEditing).toBe(false)
// @ts-expect-error - startEditing is not exposed
await vm.startEditing()
// @ts-expect-error - isEditing is not exposed
expect(vm.isEditing).toBe(true)
await wrapper.vm.$nextTick()
// Check that textarea exists after entering edit mode
const textarea = wrapper.findComponent({ name: 'Textarea' })
expect(textarea.exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,97 @@
<template>
<div
class="widget-markdown relative w-full cursor-text"
@click="startEditing"
>
<!-- Display mode: Rendered markdown -->
<div
class="comfy-markdown-content hover:bg-[var(--p-content-hover-background)] text-sm min-h-[60px] w-full rounded-lg px-4 py-2 overflow-y-auto lod-toggle"
:class="isEditing === false ? 'visible' : 'invisible'"
v-html="renderedHtml"
/>
<!-- Edit mode: Textarea -->
<Textarea
v-show="isEditing"
ref="textareaRef"
v-model="localValue"
:disabled="readonly"
class="w-full min-h-[60px] absolute inset-0 resize-none"
:pt="{
root: {
class: 'text-sm w-full h-full',
onBlur: handleBlur
}
}"
@update:model-value="onChange"
@click.stop
@keydown.stop
/>
<LODFallback />
</div>
</template>
<script setup lang="ts">
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<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 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 || '')
})
// Methods
const startEditing = async () => {
if (props.readonly || isEditing.value) return
isEditing.value = true
await nextTick()
// Focus the textarea
// @ts-expect-error - $el is an internal property of the Textarea component
textareaRef.value?.$el?.focus()
}
const handleBlur = () => {
isEditing.value = false
}
</script>
<style scoped>
.widget-markdown {
background-color: var(--p-muted-color);
border: 1px solid var(--p-border-color);
border-radius: var(--p-border-radius);
}
.comfy-markdown-content:hover {
background-color: var(--p-content-hover-background);
}
</style>

View File

@@ -0,0 +1,360 @@
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('Readonly Mode', () => {
it('disables multiselect when readonly', () => {
const widget = createMockWidget(['selected'], {
values: ['selected', 'other']
})
const wrapper = mountComponent(widget, ['selected'], true)
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('disabled')).toBe(true)
})
it('disables interaction but allows programmatic changes', async () => {
const widget = createMockWidget(['initial'], {
values: ['initial', 'other']
})
const wrapper = mountComponent(widget, ['initial'], true)
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// The MultiSelect should be disabled, preventing user interaction
expect(multiselect.props('disabled')).toBe(true)
// But programmatic changes (like from external updates) should still work
// This is the expected behavior - readonly prevents UI interaction, not programmatic updates
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const widget = createMockWidget([], {
values: ['option1', 'option2'],
placeholder: 'Select items...',
filter: true,
showClear: true
})
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('placeholder')).toBe('Select items...')
expect(multiselect.props('filter')).toBe(true)
expect(multiselect.props('showClear')).toBe(true)
})
it('excludes panel-related props', () => {
const widget = createMockWidget([], {
values: ['option1'],
overlayStyle: { color: 'red' },
panelClass: 'custom-panel'
})
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// These props should be filtered out by the prop filter
expect(multiselect.props('overlayStyle')).not.toEqual({ color: 'red' })
expect(multiselect.props('panelClass')).not.toBe('custom-panel')
})
it('handles empty values array', () => {
const widget = createMockWidget([], { values: [] })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('options')).toEqual([])
})
it('handles missing values option', () => {
const widget = createMockWidget([])
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// Should not crash, options might be undefined
expect(multiselect.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles numeric values', async () => {
const widget = createMockWidget([], {
values: [1, 2, 3, 4, 5]
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, [1, 3, 5])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([[1, 3, 5]])
})
it('handles mixed type values', async () => {
const widget = createMockWidget([], {
values: ['string', 123, true, null]
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['string', 123])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['string', 123]])
})
it('handles object values', async () => {
const objectValues = [
{ id: 1, label: 'First' },
{ id: 2, label: 'Second' }
]
const widget = createMockWidget([], {
values: objectValues,
optionLabel: 'label',
optionValue: 'id'
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, [1, 2])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([[1, 2]])
})
it('handles duplicate selections gracefully', async () => {
const widget = createMockWidget([], {
values: ['option1', 'option2']
})
const wrapper = mountComponent(widget, [])
// MultiSelect should handle duplicates internally
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option1'])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
// The actual behavior depends on PrimeVue implementation
expect(emitted![0]).toEqual([['option1', 'option1']])
})
it('handles very large option lists', () => {
const largeOptionList = Array.from(
{ length: 1000 },
(_, i) => `option${i}`
)
const widget = createMockWidget([], { values: largeOptionList })
const wrapper = mountComponent(widget, [])
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('options')).toHaveLength(1000)
})
it('handles empty string values', async () => {
const widget = createMockWidget([], {
values: ['', 'not empty', ' ', 'normal']
})
const wrapper = mountComponent(widget, [])
await setMultiSelectValueAndEmit(wrapper, ['', ' '])
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([['', ' ']])
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget([], { values: ['test'] })
const wrapper = mountComponent(widget, [])
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget([], { values: ['test'] })
widget.name = 'custom_multiselect'
const wrapper = mountComponent(widget, [])
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_multiselect')
})
})
})

View File

@@ -0,0 +1,75 @@
<template>
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
:options="multiSelectOptions"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
display="chip"
:pt="{
option: 'text-xs'
}"
@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[]
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: T[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue<T[]>({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract multiselect options from widget options
const multiSelectOptions = computed((): T[] => {
const options = props.widget.options
if (Array.isArray(options?.values)) {
return options.values
}
return []
})
</script>

View File

@@ -0,0 +1,232 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
import type { SelectProps } from 'primevue/select'
import { describe, expect, it } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelect from './WidgetSelect.vue'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
describe('WidgetSelect Value Binding', () => {
const createMockWidget = (
value: string = 'option1',
options: Partial<
SelectProps & { values?: string[]; return_index?: boolean }
> = {},
callback?: (value: string | number | undefined) => void,
spec?: ComboInputSpec
): SimplifiedWidget<string | number | undefined> => ({
name: 'test_select',
type: 'combo',
value,
options: {
values: ['option1', 'option2', 'option3'],
...options
},
callback,
spec
})
const mountComponent = (
widget: SimplifiedWidget<string | number | undefined>,
modelValue: string | number | undefined,
readonly = false
) => {
return mount(WidgetSelect, {
props: {
widget,
modelValue,
readonly
},
global: {
plugins: [PrimeVue],
components: { Select }
}
})
}
const setSelectValueAndEmit = async (
wrapper: ReturnType<typeof mount>,
value: string
) => {
const select = wrapper.findComponent({ name: 'Select' })
await select.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
})
it('emits string value for different options', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option3')
expect(emitted).toBeDefined()
// Should emit the string value
expect(emitted![0]).toContain('option3')
})
it('handles custom option values', async () => {
const customOptions = ['custom_a', 'custom_b', 'custom_c']
const widget = createMockWidget('custom_a', { values: customOptions })
const wrapper = mountComponent(widget, 'custom_a')
const emitted = await setSelectValueAndEmit(wrapper, 'custom_b')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('custom_b')
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('option1', {}, undefined)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
// Should emit Vue event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
})
it('handles value changes gracefully', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
})
})
describe('Readonly Mode', () => {
it('disables the select component when readonly', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1', true)
const select = wrapper.findComponent({ name: 'Select' })
expect(select.props('disabled')).toBe(true)
})
})
describe('Option Handling', () => {
it('handles empty options array', async () => {
const widget = createMockWidget('', { values: [] })
const wrapper = mountComponent(widget, '')
const select = wrapper.findComponent({ name: 'Select' })
expect(select.props('options')).toEqual([])
})
it('handles single option', async () => {
const widget = createMockWidget('only_option', {
values: ['only_option']
})
const wrapper = mountComponent(widget, 'only_option')
const select = wrapper.findComponent({ name: 'Select' })
const options = select.props('options')
expect(options).toHaveLength(1)
expect(options[0]).toEqual('only_option')
})
it('handles options with special characters', async () => {
const specialOptions = [
'option with spaces',
'option@#$%',
'option/with\\slashes'
]
const widget = createMockWidget(specialOptions[0], {
values: specialOptions
})
const wrapper = mountComponent(widget, specialOptions[0])
const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1])
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(specialOptions[1])
})
})
describe('Edge Cases', () => {
it('handles selection of non-existent option gracefully', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(
wrapper,
'non_existent_option'
)
// Should still emit Vue event with the value
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('non_existent_option')
})
it('handles numeric string options correctly', async () => {
const numericOptions = ['1', '2', '10', '100']
const widget = createMockWidget('1', { values: numericOptions })
const wrapper = mountComponent(widget, '1')
const emitted = await setSelectValueAndEmit(wrapper, '100')
// Should maintain string type in emitted event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('100')
})
})
describe('Spec-aware rendering', () => {
it('uses dropdown variant when combo spec enables image uploads', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
image_upload: true
}
const widget = createMockWidget('option1', {}, undefined, spec)
const wrapper = mountComponent(widget, 'option1')
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
})
it('uses dropdown variant for audio uploads', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
audio_upload: true
}
const widget = createMockWidget('clip.wav', {}, undefined, spec)
const wrapper = mountComponent(widget, 'clip.wav')
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
expect(dropdown.exists()).toBe(true)
expect(dropdown.props('assetKind')).toBe('audio')
expect(dropdown.props('allowUpload')).toBe(false)
})
it('keeps default select when no spec or media hints are present', () => {
const widget = createMockWidget('plain', {
values: ['plain', 'text']
})
const wrapper = mountComponent(widget, 'plain')
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,101 @@
<template>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault
v-else
v-bind="props"
@update:model-value="handleUpdateModelValue"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ResultItemType } from '@/schemas/apiSchema'
import {
type ComboInputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
function handleUpdateModelValue(value: string | number | undefined) {
emit('update:modelValue', value)
}
const comboSpec = computed<ComboInputSpec | undefined>(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean
folder: ResultItemType | undefined
}>(() => {
const spec = comboSpec.value
if (!spec) {
return {
kind: 'unknown',
allowUpload: false,
folder: undefined
}
}
const {
image_upload,
animated_image_upload,
video_upload,
image_folder,
audio_upload
} = spec
let kind: AssetKind = 'unknown'
if (video_upload) {
kind = 'video'
} else if (image_upload || animated_image_upload) {
kind = 'image'
} else if (audio_upload) {
kind = 'audio'
}
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
audio_upload === true
return {
kind,
allowUpload,
folder: image_folder
}
})
const assetKind = computed(() => specDescriptor.value.kind)
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
const allowUpload = computed(() => specDescriptor.value.allowUpload)
const uploadFolder = computed<ResultItemType>(() => {
return specDescriptor.value.folder ?? 'input'
})
</script>

View File

@@ -0,0 +1,433 @@
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-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
expect(unselectedButton.classes()).not.toContain('bg-white')
expect(unselectedButton.classes()).not.toContain('text-neutral-900')
})
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-white')
expect(button.classes()).not.toContain('text-neutral-900')
})
})
it('updates selection when modelValue changes', async () => {
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-white')
// Update to 'second'
await wrapper.setProps({ modelValue: 'second' })
buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[1].classes()).toContain('bg-white')
})
})
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 () => {
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('Readonly Mode', () => {
it('disables all buttons when readonly', () => {
const options = ['option1', 'option2', 'option3']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
const formSelectButton = wrapper.findComponent({
name: 'FormSelectButton'
})
expect(formSelectButton.props('disabled')).toBe(true)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.element.disabled).toBe(true)
expect(button.classes()).toContain('cursor-not-allowed')
expect(button.classes()).toContain('opacity-50')
})
})
it('does not emit changes in readonly mode', async () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
await clickSelectButton(wrapper, 'option2')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('does not change visual state in readonly mode', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
})
})
})
describe('Option Types', () => {
it('handles string options', () => {
const options = ['apple', 'banana', 'cherry']
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-white')
})
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-white')
})
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-white') // 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-white')
})
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-white') // 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-white')
expect(buttons[2].classes()).toContain('bg-white')
})
})
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 container styling', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1')
const container = wrapper.find('div').element
expect(container.className).toContain('p-1')
expect(container.className).toContain('inline-flex')
expect(container.className).toContain('justify-center')
expect(container.className).toContain('items-center')
expect(container.className).toContain('gap-1')
})
it('applies hover effects for non-selected options', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', false)
const buttons = wrapper.findAll('button')
const unselectedButton = buttons[1] // 'option2'
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget('test', { values: ['test'] })
const wrapper = mountComponent(widget, 'test')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget('test', { values: ['test'] })
widget.name = 'custom_select_button'
const wrapper = mountComponent(widget, 'test')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_select_button')
})
})
})

View File

@@ -0,0 +1,36 @@
<template>
<WidgetLayoutField :widget="widget">
<FormSelectButton
v-model="localValue"
:options="widget.options?.values || []"
:disabled="readonly"
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
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
</script>

View File

@@ -0,0 +1,70 @@
<template>
<WidgetLayoutField :widget>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="combinedProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
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'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// 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
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, ref, 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'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type {
DropdownItem,
FilterOption,
SelectedKey
} from './form/dropdown/types'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
}>()
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 toastStore = useToastStore()
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
const selectedSet = ref<Set<SelectedKey>>(new Set())
const dropdownItems = computed<DropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
return []
}
return values.map((value: string, index: number) => ({
id: index,
imageSrc: getMediaUrl(value),
name: value,
metadata: ''
}))
})
const mediaPlaceholder = computed(() => {
const options = props.widget.options
if (options?.placeholder) {
return options.placeholder
}
switch (props.assetKind) {
case 'image':
return t('widgets.uploadSelect.placeholderImage')
case 'video':
return t('widgets.uploadSelect.placeholderVideo')
case 'audio':
return t('widgets.uploadSelect.placeholderAudio')
case 'model':
return t('widgets.uploadSelect.placeholderModel')
case 'unknown':
return t('widgets.uploadSelect.placeholderUnknown')
}
return t('widgets.uploadSelect.placeholder')
})
const uploadable = computed(() => props.allowUpload === true)
watch(
localValue,
(currentValue) => {
if (currentValue !== undefined) {
const item = dropdownItems.value.find(
(item) => item.name === currentValue
)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
}
} else {
selectedSet.value.clear()
}
},
{ immediate: true }
)
function updateSelectedItems(selectedItems: Set<SelectedKey>) {
let id: SelectedKey | undefined = undefined
if (selectedItems.size > 0) {
id = selectedItems.values().next().value!
}
if (id == null) {
onChange(undefined)
return
}
const name = dropdownItems.value.find((item) => item.id === id)?.name
if (!name) {
onChange(undefined)
return
}
onChange(name)
}
// Upload file function (copied from useNodeImageUpload.ts)
const uploadFile = async (
file: File,
isPasted: boolean = false,
formFields: Partial<{ type: ResultItemType }> = {}
) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
return null
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
// Handle multiple file uploads
const uploadFiles = async (files: File[]): Promise<string[]> => {
const folder = props.uploadFolder ?? 'input'
const uploadPromises = files.map((file) =>
uploadFile(file, false, { type: folder })
)
const results = await Promise.all(uploadPromises)
return results.filter((path): path is string => path !== null)
}
async function handleFilesUpdate(files: File[]) {
if (!files || files.length === 0) return
try {
// 1. Upload files to server
const uploadedPaths = await uploadFiles(files)
if (uploadedPaths.length === 0) {
toastStore.addAlert('File upload failed')
return
}
// 2. Update widget options to include new files
// This simulates what addToComboValues does but for SimplifiedWidget
if (props.widget.options?.values) {
uploadedPaths.forEach((path) => {
const values = props.widget.options!.values as string[]
if (!values.includes(path)) {
values.push(path)
}
})
}
// 3. Update widget value to the first uploaded file
onChange(uploadedPaths[0])
// 4. Trigger callback to notify underlying LiteGraph widget
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)
}
}
function getMediaUrl(filename: string): string {
if (props.assetKind !== 'image') return ''
// TODO: This needs to be adapted based on actual ComfyUI API structure
return `/api/view?filename=${encodeURIComponent(filename)}&type=input`
}
// TODO handle filter logic
const filterSelected = ref('all')
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'image', name: 'Inputs' },
{ id: 'video', name: 'Outputs' }
])
</script>
<template>
<WidgetLayoutField :widget>
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
:items="dropdownItems"
:placeholder="mediaPlaceholder"
:multiple="false"
:uploadable="uploadable"
:disabled="readonly"
:filter-options="filterOptions"
v-bind="combinedProps"
class="w-full"
@update:selected="updateSelectedItems"
@update:files="handleFilesUpdate"
/>
</WidgetLayoutField>
</template>

View File

@@ -0,0 +1,260 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetTextarea from './WidgetTextarea.vue'
function createMockWidget(
value: string = 'default text',
options: SimplifiedWidget['options'] = {},
callback?: (value: string) => void
): SimplifiedWidget<string> {
return {
name: 'test_textarea',
type: 'string',
value,
options,
callback
}
}
function mountComponent(
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false,
placeholder?: string
) {
return mount(WidgetTextarea, {
global: {
plugins: [PrimeVue],
components: { Textarea }
},
props: {
widget,
modelValue,
readonly,
placeholder
}
})
}
async function setTextareaValueAndTrigger(
wrapper: ReturnType<typeof mount>,
value: string,
trigger: 'blur' | 'input' = 'blur'
) {
const textarea = wrapper.find('textarea')
if (!(textarea.element instanceof HTMLTextAreaElement)) {
throw new Error(
'Textarea element not found or is not an HTMLTextAreaElement'
)
}
await textarea.setValue(value)
await textarea.trigger(trigger)
return textarea
}
describe('WidgetTextarea Value Binding', () => {
describe('Vue Event Emission', () => {
it('emits Vue event when textarea value changes on blur', async () => {
const widget = createMockWidget('hello')
const wrapper = mountComponent(widget, 'hello')
await setTextareaValueAndTrigger(wrapper, 'world', 'blur')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('world')
})
it('emits Vue event when textarea value changes on input', async () => {
const widget = createMockWidget('initial')
const wrapper = mountComponent(widget, 'initial')
await setTextareaValueAndTrigger(wrapper, 'new content', 'input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('new content')
})
it('handles empty string values', async () => {
const widget = createMockWidget('something')
const wrapper = mountComponent(widget, 'something')
await setTextareaValueAndTrigger(wrapper, '')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('')
})
it('handles multiline text correctly', async () => {
const widget = createMockWidget('single line')
const wrapper = mountComponent(widget, 'single line')
const multilineText = 'Line 1\nLine 2\nLine 3'
await setTextareaValueAndTrigger(wrapper, multilineText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(multilineText)
})
it('handles special characters correctly', async () => {
const widget = createMockWidget('normal')
const wrapper = mountComponent(widget, 'normal')
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
await setTextareaValueAndTrigger(wrapper, specialText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(specialText)
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('test', {}, undefined)
const wrapper = mountComponent(widget, 'test')
await setTextareaValueAndTrigger(wrapper, 'new value')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('new value')
})
})
describe('User Interactions', () => {
it('emits update:modelValue on blur', async () => {
const widget = createMockWidget('original')
const wrapper = mountComponent(widget, 'original')
await setTextareaValueAndTrigger(wrapper, 'updated')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('updated')
})
it('emits update:modelValue on input', async () => {
const widget = createMockWidget('start')
const wrapper = mountComponent(widget, 'start')
await setTextareaValueAndTrigger(wrapper, 'finish', 'input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('finish')
})
})
describe('Readonly Mode', () => {
it('disables textarea when readonly', () => {
const widget = createMockWidget('readonly test')
const wrapper = mountComponent(widget, 'readonly test', true)
const textarea = wrapper.find('textarea')
if (!(textarea.element instanceof HTMLTextAreaElement)) {
throw new Error(
'Textarea element not found or is not an HTMLTextAreaElement'
)
}
expect(textarea.element.disabled).toBe(true)
})
})
describe('Component Rendering', () => {
it('renders textarea component', () => {
const widget = createMockWidget('test value')
const wrapper = mountComponent(widget, 'test value')
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
})
it('displays initial value in textarea', () => {
const widget = createMockWidget('initial content')
const wrapper = mountComponent(widget, 'initial content')
const textarea = wrapper.find('textarea')
if (!(textarea.element instanceof HTMLTextAreaElement)) {
throw new Error(
'Textarea element not found or is not an HTMLTextAreaElement'
)
}
expect(textarea.element.value).toBe('initial content')
})
it('uses widget name as placeholder when no placeholder provided', () => {
const widget = createMockWidget('test')
const wrapper = mountComponent(widget, 'test')
const textarea = wrapper.find('textarea')
expect(textarea.attributes('placeholder')).toBe('test_textarea')
})
it('uses provided placeholder when specified', () => {
const widget = createMockWidget('test')
const wrapper = mountComponent(
widget,
'test',
false,
'Custom placeholder'
)
const textarea = wrapper.find('textarea')
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
})
it('sets default rows attribute', () => {
const widget = createMockWidget('test')
const wrapper = mountComponent(widget, 'test')
const textarea = wrapper.find('textarea')
expect(textarea.attributes('rows')).toBe('3')
})
})
describe('Edge Cases', () => {
it('handles very long text', async () => {
const widget = createMockWidget('short')
const wrapper = mountComponent(widget, 'short')
const longText = 'a'.repeat(10000)
await setTextareaValueAndTrigger(wrapper, longText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(longText)
})
it('handles unicode characters', async () => {
const widget = createMockWidget('ascii')
const wrapper = mountComponent(widget, 'ascii')
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
await setTextareaValueAndTrigger(wrapper, unicodeText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(unicodeText)
})
it('handles text with tabs and spaces', async () => {
const widget = createMockWidget('normal')
const wrapper = mountComponent(widget, 'normal')
const formattedText = '\tIndented line\n Spaced line\n\t\tDouble indent'
await setTextareaValueAndTrigger(wrapper, formattedText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(formattedText)
})
})
})

View File

@@ -0,0 +1,53 @@
<template>
<div class="relative">
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
:placeholder="placeholder || widget.name || ''"
size="small"
rows="3"
@update:model-value="onChange"
/>
<LODFallback />
</div>
</template>
<script setup lang="ts">
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 {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import LODFallback from '../../components/LODFallback.vue'
import { WidgetInputBaseClass } from './layout'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
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 filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,160 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
options: Partial<ToggleSwitchProps> = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean> => ({
name: 'test_toggle',
type: 'boolean',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<boolean>,
modelValue: boolean,
readonly = false
) => {
return mount(WidgetToggleSwitch, {
props: {
widget,
modelValue,
readonly
},
global: {
plugins: [PrimeVue],
components: { ToggleSwitch }
}
})
}
describe('Vue Event Emission', () => {
it('emits Vue event when toggled from false to true', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(true)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(true)
})
it('emits Vue event when toggled from true to false', async () => {
const widget = createMockWidget(true)
const wrapper = mountComponent(widget, true)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(false)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(false)
})
it('handles value changes gracefully', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
// Should not throw when changing values
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(true)
await toggle.setValue(false)
// Should emit events for all changes
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(2)
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
})
})
describe('Component Rendering', () => {
it('renders toggle switch component', () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
true
)
})
it('displays correct initial state for false', () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('modelValue')).toBe(false)
})
it('displays correct initial state for true', () => {
const widget = createMockWidget(true)
const wrapper = mountComponent(widget, true)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('modelValue')).toBe(true)
})
it('disables component in readonly mode', () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false, true)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('disabled')).toBe(true)
})
})
describe('Multiple Value Changes', () => {
it('handles rapid toggling correctly', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
// Rapid toggle sequence
await toggle.setValue(true)
await toggle.setValue(false)
await toggle.setValue(true)
// Should have emitted 3 Vue events with correct values
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(3)
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
expect(emitted![2]).toContain(true)
})
it('maintains state consistency during multiple changes', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
// Multiple state changes
await toggle.setValue(true)
await toggle.setValue(false)
await toggle.setValue(true)
await toggle.setValue(false)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(4)
// Verify alternating pattern
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
expect(emitted![2]).toContain(true)
expect(emitted![3]).toContain(false)
})
})
})

View File

@@ -0,0 +1,55 @@
<template>
<WidgetLayoutField :widget="widget">
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
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,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: 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 filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-toggleswitch .p-toggleswitch-slider) {
border: 1px solid transparent;
}
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
border-color: currentColor;
}
</style>

View File

@@ -0,0 +1,538 @@
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, { 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('Readonly Mode', () => {
it('disables treeselect when readonly', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('disabled')).toBe(true)
})
it('does not emit changes in readonly mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
// Try to emit a change (though the component should prevent it)
await setTreeSelectValueAndEmit(wrapper, { key: '0-0-0', label: 'Test' })
// The component will still emit the event, but the disabled prop should prevent interaction
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined() // The event is emitted but the TreeSelect should be disabled
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
placeholder: 'Select a node...',
filter: true,
showClear: true,
selectionMode: 'single'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('placeholder')).toBe('Select a node...')
expect(treeSelect.props('filter')).toBe(true)
expect(treeSelect.props('showClear')).toBe(true)
expect(treeSelect.props('selectionMode')).toBe('single')
})
it('excludes panel-related props', () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
inputClass: 'custom-input',
inputStyle: { color: 'red' },
panelClass: 'custom-panel'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
// These props should be filtered out by the widgetPropFilter
const inputClass = treeSelect.props('inputClass')
const inputStyle = treeSelect.props('inputStyle')
// Either undefined or null are acceptable as "excluded"
expect(inputClass == null).toBe(true)
expect(inputStyle == null).toBe(true)
expect(treeSelect.exists()).toBe(true)
})
it('handles empty options gracefully', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual([])
})
it('handles missing options gracefully', () => {
const widget = createMockWidget(null)
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
// Should not crash, options might be undefined
expect(treeSelect.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles malformed tree nodes', () => {
const malformedOptions: unknown[] = [
{ key: 'empty', label: 'Empty Object' }, // Valid object to prevent issues
{ key: 'random', label: 'Random', randomProp: 'value' } // Object with extra properties
]
const widget = createMockWidget(null, {
options: malformedOptions as TreeNode[]
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(malformedOptions)
})
it('handles nodes with missing keys', () => {
const noKeyOptions = [
{ key: 'generated-1', label: 'No Key 1', leaf: true },
{ key: 'generated-2', label: 'No Key 2', leaf: true }
] as TreeNode[]
const widget = createMockWidget(null, { options: noKeyOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(noKeyOptions)
})
it('handles nodes with missing labels', () => {
const noLabelOptions: TreeNode[] = [
{ key: 'key1', leaf: true },
{ key: 'key2', leaf: true }
]
const widget = createMockWidget(null, { options: noLabelOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(noLabelOptions)
})
it('handles very large tree structure', () => {
const largeTree: TreeNode[] = Array.from({ length: 100 }, (_, i) => ({
key: `node${i}`,
label: `Node ${i}`,
children: Array.from({ length: 10 }, (_, j) => ({
key: `node${i}-${j}`,
label: `Child ${j}`,
leaf: true
}))
}))
const widget = createMockWidget(null, { options: largeTree })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toHaveLength(100)
})
it('handles tree with circular references safely', () => {
// Create nodes that could potentially have circular references
const circularOptions: TreeNode[] = [
{
key: 'parent',
label: 'Parent',
children: [{ key: 'child1', label: 'Child 1', leaf: true }]
}
]
const widget = createMockWidget(null, { options: circularOptions })
expect(() => mountComponent(widget, null)).not.toThrow()
})
it('handles nodes with special characters', () => {
const specialCharOptions: TreeNode[] = [
{ key: '@#$%^&*()', label: 'Special Chars @#$%', leaf: true },
{
key: '{}[]|\\:";\'<>?,./`~',
label: 'More Special {}[]|\\',
leaf: true
}
]
const widget = createMockWidget(null, { options: specialCharOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(specialCharOptions)
})
it('handles unicode in node labels', () => {
const unicodeOptions: TreeNode[] = [
{ key: 'unicode1', label: '🌟 Unicode Star', leaf: true },
{ key: 'unicode2', label: '中文 Chinese', leaf: true },
{ key: 'unicode3', label: 'العربية Arabic', leaf: true }
]
const widget = createMockWidget(null, { options: unicodeOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(unicodeOptions)
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget(null, { options: [] })
widget.name = 'custom_treeselect'
const wrapper = mountComponent(widget, null)
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_treeselect')
})
})
})

View File

@@ -0,0 +1,69 @@
<template>
<WidgetLayoutField :widget="widget">
<TreeSelect
v-model="localValue"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@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
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
</script>

View File

@@ -0,0 +1,507 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import FormSelectButton from './FormSelectButton.vue'
describe('FormSelectButton Core Component', () => {
// Type-safe helper for mounting component
const mountComponent = (
modelValue: string | null | undefined = null,
options: (string | number | Record<string, any>)[] = [],
props: Record<string, unknown> = {}
) => {
return mount(FormSelectButton, {
global: {
plugins: [PrimeVue]
},
props: {
modelValue,
options: options as any,
...props
}
})
}
const clickButton = async (
wrapper: ReturnType<typeof mount>,
buttonText: string
) => {
const buttons = wrapper.findAll('button')
const targetButtonIndex = buttons.findIndex((button) =>
button.text().includes(buttonText)
)
if (targetButtonIndex === -1) {
throw new Error(`Button with text "${buttonText}" not found`)
}
// Use get() which throws if element doesn't exist, providing better error messages
const targetButton = buttons.at(targetButtonIndex)!
await targetButton.trigger('click')
return targetButton
}
describe('Basic Rendering', () => {
it('renders as a horizontal button group layout', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent(null, options)
const container = wrapper.find('div')
const buttons = wrapper.findAll('button')
// Verify layout behavior: container exists and contains buttons
expect(container.exists()).toBe(true)
expect(buttons).toHaveLength(2)
// Verify buttons are arranged horizontally (not vertically stacked)
// This tests the layout logic rather than specific CSS classes
buttons.forEach((button) => {
expect(button.exists()).toBe(true)
expect(button.element.tagName).toBe('BUTTON')
})
})
it('renders buttons for each option', () => {
const options = ['first', 'second', 'third']
const wrapper = mountComponent(null, options)
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('renders empty container when no options provided', () => {
const wrapper = mountComponent(null, [])
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(0)
})
it('applies proper button styling', () => {
const options = ['test']
const wrapper = mountComponent(null, options)
const button = wrapper.find('button')
expect(button.classes()).toContain('flex-1')
expect(button.classes()).toContain('h-6')
expect(button.classes()).toContain('px-5')
expect(button.classes()).toContain('py-[5px]')
expect(button.classes()).toContain('rounded')
expect(button.classes()).toContain('text-center')
expect(button.classes()).toContain('text-xs')
expect(button.classes()).toContain('font-normal')
})
})
describe('String Options', () => {
it('handles string array options', () => {
const options = ['apple', 'banana', 'cherry']
const wrapper = mountComponent('banana', options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('apple')
expect(buttons[1].text()).toBe('banana')
expect(buttons[2].text()).toBe('cherry')
})
it('emits correct string value when clicked', async () => {
const options = ['first', 'second', 'third']
const wrapper = mountComponent('first', options)
await clickButton(wrapper, 'second')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['second'])
})
it('highlights selected string option', () => {
const options = ['option1', 'option2', 'option3']
const wrapper = mountComponent('option2', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
})
})
describe('Number Options', () => {
it('handles number array options', () => {
const options = [1, 2, 3]
const wrapper = mountComponent('2', options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('1')
expect(buttons[1].text()).toBe('2')
expect(buttons[2].text()).toBe('3')
})
it('emits string representation of number when clicked', async () => {
const options = [10, 20, 30]
const wrapper = mountComponent('10', options)
await clickButton(wrapper, '20')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['20'])
})
it('highlights selected number option', () => {
const options = [100, 200, 300]
const wrapper = mountComponent('200', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
})
})
describe('Object Options', () => {
it('handles object array with label and value', () => {
const options = [
{ label: 'First Option', value: 'first' },
{ label: 'Second Option', value: 'second' }
]
const wrapper = mountComponent('first', options)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].text()).toBe('First Option')
expect(buttons[1].text()).toBe('Second Option')
})
it('emits object value when object option clicked', async () => {
const options = [
{ label: 'Apple', value: 'apple_val' },
{ label: 'Banana', value: 'banana_val' }
]
const wrapper = mountComponent('apple_val', options)
await clickButton(wrapper, 'Banana')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['banana_val'])
})
it('highlights selected object option by value', () => {
const options = [
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' }
]
const wrapper = mountComponent('md', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white') // Medium
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
})
it('handles objects without value field', () => {
const options = [
{ label: 'First', name: 'first_name' },
{ label: 'Second', name: 'second_name' }
]
const wrapper = mountComponent('first_name', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First')
expect(buttons[1].text()).toBe('Second')
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles objects without label field', () => {
const options = [
{ value: 'val1', name: 'Name 1' },
{ value: 'val2', name: 'Name 2' }
]
const wrapper = mountComponent('val1', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('Name 1')
expect(buttons[1].text()).toBe('Name 2')
})
})
describe('PrimeVue Compatibility', () => {
it('uses custom optionLabel prop', () => {
const options = [
{ title: 'First Item', value: 'first' },
{ title: 'Second Item', value: 'second' }
]
const wrapper = mountComponent('first', options, { optionLabel: 'title' })
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First Item')
expect(buttons[1].text()).toBe('Second Item')
})
it('uses custom optionValue prop', () => {
const options = [
{ label: 'First', id: 'first_id' },
{ label: 'Second', id: 'second_id' }
]
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('emits custom optionValue when clicked', async () => {
const options = [
{ label: 'First', id: 'first_id' },
{ label: 'Second', id: 'second_id' }
]
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
await clickButton(wrapper, 'Second')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual(['second_id'])
})
})
describe('Disabled State', () => {
it('disables all buttons when disabled prop is true', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.element.disabled).toBe(true)
expect(button.classes()).toContain('opacity-50')
expect(button.classes()).toContain('cursor-not-allowed')
})
})
it('does not emit events when disabled', async () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
await clickButton(wrapper, 'option2')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('does not apply hover styles when disabled', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
expect(button.classes()).not.toContain('cursor-pointer')
})
})
it('applies disabled styling to selected option', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain('bg-white') // Selected styling disabled
expect(buttons[0].classes()).toContain('opacity-50')
expect(buttons[0].classes()).toContain('text-zinc-500')
})
})
describe('Selection Logic', () => {
it('handles null modelValue', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent(null, options)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
})
})
it('handles undefined modelValue', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent(undefined, options)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
})
})
it('handles empty string modelValue', () => {
const options = ['', 'option1', 'option2']
const wrapper = mountComponent('', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('compares values as strings', () => {
const options = [1, '2', 3]
const wrapper = mountComponent('1', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white') // '1' matches number 1 as string
})
})
describe('Visual States', () => {
it('applies selected styling to active option', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options)
const selectedButton = wrapper.findAll('button')[0]
expect(selectedButton.classes()).toContain('bg-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
})
it('applies unselected styling to inactive options', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options)
const unselectedButton = wrapper.findAll('button')[1]
expect(unselectedButton.classes()).toContain('bg-transparent')
expect(unselectedButton.classes()).toContain('text-zinc-500')
})
it('applies hover effects to enabled unselected buttons', () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: false })
const unselectedButton = wrapper.findAll('button')[1]
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})
describe('Edge Cases', () => {
it('handles very long option text', () => {
const longText =
'This is a very long option text that might cause layout issues'
const options = ['short', longText, 'normal']
const wrapper = mountComponent('short', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].text()).toBe(longText)
expect(buttons).toHaveLength(3)
})
it('handles options with special characters', () => {
const specialOptions = ['@#$%^&*()', '{}[]|\\:";\'<>?,./']
const wrapper = mountComponent(specialOptions[0], specialOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('@#$%^&*()')
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles unicode characters in options', () => {
const unicodeOptions = ['🎨 Art', '中文', 'العربية']
const wrapper = mountComponent('🎨 Art', unicodeOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('🎨 Art')
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles duplicate option values', () => {
const duplicateOptions = ['duplicate', 'unique', 'duplicate']
const wrapper = mountComponent('duplicate', duplicateOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain('bg-white') // Both duplicates selected
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('handles mixed type options safely', () => {
const mixedOptions: any[] = [
'string',
123,
{ label: 'Object', value: 'obj' },
null
]
const wrapper = mountComponent('123', mixedOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[1].classes()).toContain('bg-white') // Number 123 as string
})
it('handles objects with missing properties gracefully', () => {
const incompleteOptions = [
{}, // Empty object
{ randomProp: 'value' }, // No standard props
{ value: 'has_value' }, // No label
{ label: 'has_label' } // No value
]
const wrapper = mountComponent('has_value', incompleteOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[2].classes()).toContain('bg-white')
})
it('handles large number of options', () => {
const manyOptions = Array.from(
{ length: 50 },
(_, i) => `Option ${i + 1}`
)
const wrapper = mountComponent('Option 25', manyOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(50)
expect(buttons[24].classes()).toContain('bg-white') // Option 25 at index 24
})
it('fallback to index when all object properties are missing', () => {
const problematicOptions = [
{ someRandomProp: 'random1' },
{ anotherRandomProp: 'random2' }
]
const wrapper = mountComponent('0', problematicOptions)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].classes()).toContain('bg-white') // Falls back to index 0
})
})
describe('Event Handling', () => {
it('prevents click events when disabled', async () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options, { disabled: true })
const clickHandler = vi.fn()
wrapper.vm.$el.addEventListener('click', clickHandler)
await clickButton(wrapper, 'option2')
expect(clickHandler).not.toHaveBeenCalled()
})
it('allows repeated selection of same option', async () => {
const options = ['option1', 'option2']
const wrapper = mountComponent('option1', options)
await clickButton(wrapper, 'option1')
await clickButton(wrapper, 'option1')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(2)
expect(emitted![0]).toEqual(['option1'])
expect(emitted![1]).toEqual(['option1'])
})
})
})

View File

@@ -0,0 +1,108 @@
<template>
<div
:class="
cn(
WidgetInputBaseClass,
'p-1 inline-flex justify-center items-center gap-1'
)
"
>
<button
v-for="(option, index) in options"
:key="getOptionValue(option, index)"
:class="
cn(
'flex-1 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out',
'bg-transparent border-none',
'text-center text-xs font-normal',
{
'bg-white': isSelected(option) && !disabled,
'hover:bg-zinc-200/50': !isSelected(option) && !disabled,
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled
},
{
'text-neutral-900': isSelected(option) && !disabled,
'text-zinc-500': !isSelected(option) || disabled
}
)
"
:disabled="disabled"
@click="handleSelect(option)"
>
{{ getOptionLabel(option) }}
</button>
</div>
</template>
<script
setup
lang="ts"
generic="T extends string | number | { label: string; value: any }"
>
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../layout'
interface Props {
modelValue: string | null | undefined
options: T[]
optionLabel?: string // PrimeVue compatible prop
optionValue?: string // PrimeVue compatible prop
disabled?: boolean
}
interface Emits {
'update:modelValue': [value: string]
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
optionLabel: 'label',
optionValue: 'value'
})
const emit = defineEmits<Emits>()
// handle both string/number arrays and object arrays with PrimeVue compatibility
const getOptionValue = (option: T, index: number): string => {
if (typeof option === 'object' && option !== null) {
const valueField = props.optionValue
const value =
(option as any)[valueField] ??
(option as any).value ??
(option as any).name ??
(option as any).label ??
index
return String(value)
}
return String(option)
}
// for display with PrimeVue compatibility
const getOptionLabel = (option: T): string => {
if (typeof option === 'object' && option !== null) {
const labelField = props.optionLabel
return (
(option as any)[labelField] ??
(option as any).label ??
(option as any).name ??
(option as any).value ??
String(option)
)
}
return String(option)
}
const isSelected = (option: T): boolean => {
const optionValue = getOptionValue(option, props.options.indexOf(option))
return optionValue === String(props.modelValue ?? '')
}
const handleSelect = (option: T) => {
if (props.disabled) return
const optionValue = getOptionValue(option, props.options.indexOf(option))
emit('update:modelValue', optionValue)
}
</script>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SelectedKey,
SortOption
} from './types'
interface Props {
items: DropdownItem[]
placeholder?: string
/**
* If true, allows multiple selections. If a number is provided,
* it specifies the maximum number of selections allowed.
*/
multiple?: boolean | number
uploadable?: boolean
disabled?: boolean
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
selected: Set<SelectedKey>,
item: DropdownItem,
index: number
) => boolean
searcher?: (
query: string,
items: DropdownItem[],
onCleanup: (cleanupFn: () => void) => void
) => Promise<DropdownItem[]>
}
const props = withDefaults(defineProps<Props>(), {
placeholder: t('widgets.uploadSelect.placeholder'),
multiple: false,
uploadable: false,
disabled: false,
filterOptions: () => [],
sortOptions: () => getDefaultSortOptions(),
isSelected: (selected, item, _index) => selected.has(item.id),
searcher: defaultSearcher
})
const selected = defineModel<Set<SelectedKey>>('selected', {
default: new Set()
})
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
const sortSelected = defineModel<OptionId>('sortSelected', {
default: 'default'
})
const layoutMode = defineModel<LayoutMode>('layoutMode', {
default: 'grid'
})
const files = defineModel<File[]>('files', { default: [] })
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
maxWait: 700
})
const isQuerying = ref(false)
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const isOpen = ref(false)
const maxSelectable = computed(() => {
if (props.multiple === true) return Infinity
if (typeof props.multiple === 'number') return props.multiple
return 1
})
const filteredItems = ref<DropdownItem[]>([])
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
watch(
debouncedSearchQuery,
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
void props
.searcher(
debouncedSearchQuery.value,
props.items,
(cb) => (cleanupFn = cb)
)
.then((result) => {
if (!isCleanup) filteredItems.value = result
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
},
{ immediate: true }
)
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = props.sortOptions.find(
(option) => option.id === 'default'
)?.sorter
return sorter || (({ items }) => items.slice())
})
const selectedSorter = computed<SortOption['sorter']>(() => {
if (sortSelected.value === 'default') return defaultSorter.value
const sorter = props.sortOptions.find(
(option) => option.id === sortSelected.value
)?.sorter
return sorter || defaultSorter.value
})
const sortedItems = computed(() => {
return selectedSorter.value({ items: filteredItems.value }) || []
})
function internalIsSelected(item: DropdownItem, index: number): boolean {
return props.isSelected?.(selected.value, item, index) ?? false
}
const toggleDropdown = (event: Event) => {
if (props.disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle(event, triggerRef.value)
isOpen.value = !isOpen.value
}
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide()
isOpen.value = false
}
}
function handleFileChange(event: Event) {
if (props.disabled) return
const input = event.target as HTMLInputElement
if (input.files) {
files.value = Array.from(input.files)
}
// Clear the input value to allow re-selecting the same file
input.value = ''
}
function handleSelection(item: DropdownItem, index: number) {
if (props.disabled) return
const sel = selected.value
if (internalIsSelected(item, index)) {
sel.delete(item.id)
} else {
if (sel.size < maxSelectable.value) {
sel.add(item.id)
} else if (maxSelectable.value === 1) {
sel.clear()
sel.add(item.id)
} else {
toastStore.addAlert(`Maximum selection limit reached`)
return
}
}
selected.value = new Set(sel)
if (maxSelectable.value === 1) {
closeDropdown()
}
}
</script>
<template>
<div ref="triggerRef">
<FormDropdownInput
:files="files"
:is-open="isOpen"
:placeholder="placeholder"
:items="items"
:max-selectable="maxSelectable"
:selected="selected"
:uploadable="uploadable"
:disabled="disabled"
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:filter-options="filterOptions"
:sort-options="sortOptions"
:disabled="disabled"
:is-querying="isQuerying"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable="maxSelectable"
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../../layout'
import type { DropdownItem, SelectedKey } from './types'
interface Props {
isOpen?: boolean
placeholder?: string
files: File[]
items: DropdownItem[]
selected: Set<SelectedKey>
maxSelectable: number
uploadable: boolean
disabled: boolean
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
placeholder: 'Select...'
})
const emit = defineEmits<{
(e: 'select-click', event: MouseEvent): void
(e: 'file-change', event: Event): void
}>()
const selectedItems = computed(() => {
return props.items.filter((item) => props.selected.has(item.id))
})
const chevronClass = computed(() =>
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
'rotate-180': props.isOpen
})
)
const theButtonStyle = computed(() => [
'bg-transparent border-0 outline-none text-zinc-400',
{
'hover:bg-zinc-500/30 hover:text-black hover:dark-theme:text-white cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
}
])
</script>
<template>
<div
:class="
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
})
"
>
<!-- Dropdown -->
<button
:class="
cn(theButtonStyle, 'flex justify-between items-center flex-1 h-8', {
'rounded-l-lg': uploadable,
'rounded-lg': !uploadable
})
"
@click="emit('select-click', $event)"
>
<span class="px-4 py-2 min-w-0 text-left">
<span v-if="!selectedItems.length" class="min-w-0">
{{ props.placeholder }}
</span>
<span v-else class="line-clamp-1 min-w-0 break-all">
{{ selectedItems.map((item) => (item as any)?.name).join(', ') }}
</span>
</span>
<i-lucide:chevron-down :class="chevronClass" />
</button>
<!-- Open File -->
<label
v-if="uploadable"
:class="
cn(
theButtonStyle,
'relative',
'size-8 flex justify-center items-center border-l rounded-r-lg border-zinc-300/10'
)
"
>
<i-lucide:folder-search class="size-4" />
<input
type="file"
class="opacity-0 absolute inset-0 -z-1"
:multiple="maxSelectable > 1"
:disabled="disabled"
@change="emit('file-change', $event)"
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SortOption
} from './types'
interface Props {
items: DropdownItem[]
isSelected: (item: DropdownItem, index: number) => boolean
isQuerying: boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: DropdownItem, index: number): void
}>()
// Define models for two-way binding
const filterSelected = defineModel<OptionId>('filterSelected')
const layoutMode = defineModel<LayoutMode>('layoutMode')
const sortSelected = defineModel<OptionId>('sortSelected')
const searchQuery = defineModel<string>('searchQuery')
// Handle item selection
</script>
<template>
<div
class="w-103 h-[640px] pt-4 bg-white dark-theme:bg-charcoal-800 rounded-lg outline outline-offset-[-1px] outline-sand-100 dark-theme:outline-zinc-800 flex flex-col"
>
<!-- Filter -->
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"
v-model:filter-selected="filterSelected"
:filter-options="filterOptions"
/>
<!-- Actions -->
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:sort-options="sortOptions"
:is-querying="isQuerying"
/>
<!-- List -->
<div class="flex overflow-hidden relative h-full">
<div
:class="
cn(
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
{
'grid-cols-4': layoutMode === 'grid',
'grid-cols-1 gap-y-2': layoutMode === 'list',
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
}
)
"
>
<div
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-white dark-theme:from-neutral-900 to-transparent pointer-events-none z-10"
/>
<div
v-if="items.length === 0"
class="flex justify-center items-center absolute inset-0"
>
<i-lucide:circle-off
title="No items"
class="size-30 text-zinc-500/20"
/>
</div>
<!-- Item -->
<FormDropdownMenuItem
v-for="(item, index) in items"
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:image-src="item.imageSrc"
:name="item.name"
:metadata="item.metadata"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode, OptionId, SortOption } from './types'
defineProps<{
isQuerying: boolean
sortOptions: SortOption[]
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
const searchQuery = defineModel<string>('searchQuery')
const sortSelected = defineModel<OptionId>('sortSelected')
const actionButtonStyle =
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-sand-100 dark-theme:outline-neutral-700 transition-all duration-150'
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
const layoutSwitchItemStyle =
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-black hover:dark-theme:text-white active:scale-95'
const sortPopoverRef = useTemplateRef('sortPopoverRef')
const sortTriggerRef = useTemplateRef('sortTriggerRef')
const isSortPopoverOpen = ref(false)
function toggleSortPopover(event: Event) {
if (!sortPopoverRef.value || !sortTriggerRef.value) return
isSortPopoverOpen.value = !isSortPopoverOpen.value
sortPopoverRef.value.toggle(event, sortTriggerRef.value)
}
function closeSortPopover() {
isSortPopoverOpen.value = false
sortPopoverRef.value?.hide()
}
function handleSortSelected(item: SortOption) {
sortSelected.value = item.id
closeSortPopover()
}
</script>
<template>
<div class="flex gap-2 text-zinc-400 px-4">
<label
:class="
cn(
actionButtonStyle,
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-black dark-theme:text-white' : '',
'hover:!outline-blue-500/80',
'focus-within:!outline-blue-500/80'
)
"
>
<i-lucide:loader-circle
v-if="isQuerying"
class="mr-2 size-4 animate-spin"
/>
<i-lucide:search v-else class="mr-2 size-4" />
<input
v-model="searchQuery"
type="text"
:class="resetInputStyle"
placeholder="Search"
/>
</label>
<!-- Sort Select -->
<button
ref="sortTriggerRef"
:class="
cn(
resetInputStyle,
actionButtonStyle,
'relative w-8 flex justify-center items-center cursor-pointer',
'hover:!outline-blue-500/80',
'active:!scale-95'
)
"
@click="toggleSortPopover"
>
<div
v-if="sortSelected !== 'default'"
class="size-2 absolute top-[-2px] left-[-2px] bg-blue-500 rounded-full"
/>
<i-lucide:arrow-up-down class="size-4" />
</button>
<!-- Sort Popover -->
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isSortPopoverOpen = false"
>
<div
:class="
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-zinc-200 dark-theme:bg-charcoal-700',
'rounded-lg outline outline-offset-[-1px] outline-sand-200 dark-theme:outline-zinc-700'
)
"
>
<button
v-for="item of sortOptions"
:key="item.name"
:class="
cn(
resetInputStyle,
'flex justify-between items-center h-6 cursor-pointer',
'hover:!text-blue-500'
)
"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
<i-lucide:check v-if="sortSelected === item.id" class="size-4" />
</button>
</div>
</Popover>
<!-- Layout Switch -->
<div
:class="
cn(
actionButtonStyle,
'flex justify-center items-center p-1 gap-1 hover:!outline-blue-500/80'
)
"
>
<button
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'list'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
: ''
)
"
@click="layoutMode = 'list'"
>
<i-lucide:list class="size-4" />
</button>
<button
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'grid'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
: ''
)
"
@click="layoutMode = 'grid'"
>
<i-lucide:layout-grid class="size-4" />
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import type { FilterOption, OptionId } from './types'
defineProps<{
filterOptions: FilterOption[]
}>()
const filterSelected = defineModel<OptionId>('filterSelected')
</script>
<template>
<div class="flex gap-1 text-zinc-400 px-4 mb-4">
<div
v-for="option in filterOptions"
:key="option.id"
:class="
cn(
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
'transition-all duration-150',
'hover:text-black hover:dark-theme:text-white hover:bg-zinc-500/10',
'active:scale-95',
filterSelected === option.id
? '!bg-zinc-500/20 text-black dark-theme:text-white'
: 'bg-transparent'
)
"
@click="filterSelected = option.id"
>
{{ option.name }}
</div>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode } from './types'
interface Props {
index: number
selected: boolean
imageSrc: string
name: string
metadata?: string
layout?: LayoutMode
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [index: number]
imageLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('imageLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
</script>
<template>
<div
:class="
cn(
'flex gap-1 select-none group/item cursor-pointer',
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
layout === 'list',
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
layout === 'list-small',
// selection
'ring-2 ring-blue-500': layout === 'list' && selected
}
)
"
@click="handleClick"
>
<!-- Image -->
<div
v-if="layout !== 'list-small'"
:class="
cn(
'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
'transition-all duration-150',
{
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
layout === 'grid',
// selection
'ring-2 ring-blue-500': layout === 'grid' && selected
}
)
"
>
<!-- Selected Icon -->
<div
v-if="selected"
class="rounded-full bg-blue-500 border-1 border-white size-4 absolute top-1 left-1"
>
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
</div>
<img
v-if="imageSrc"
:src="imageSrc"
class="size-full object-cover"
@load="handleImageLoad"
/>
<div
v-else
class="size-full bg-gradient-to-tr from-blue-400 via-teal-500 to-green-400"
/>
</div>
<!-- Name -->
<div
:class="
cn('flex gap-1', {
'flex-col': layout === 'grid',
'flex-col px-4 py-1 w-full justify-center': layout === 'list',
'flex-row p-2 items-center justify-between w-full':
layout === 'list-small'
})
"
>
<span
:class="
cn(
'block text-[15px] line-clamp-2 wrap-break-word',
'transition-colors duration-150',
// selection
!!selected && 'text-blue-500'
)
"
>
{{ name }}
</span>
<!-- Meta Data -->
<span class="block text-xs text-slate-400">{{
metadata || actualDimensions
}}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
import type { DropdownItem, SortOption } from './types'
export async function defaultSearcher(query: string, items: DropdownItem[]) {
if (query.trim() === '') return items
const words = query.trim().toLowerCase().split(' ')
return items.filter((item) => {
const name = item.name.toLowerCase()
return words.every((word) => name.includes(word))
})
}
export function getDefaultSortOptions(): SortOption[] {
return [
{
name: 'Default',
id: 'default',
sorter: ({ items }) => items.slice()
},
{
name: 'A-Z',
id: 'a-z',
sorter: ({ items }) =>
items.slice().sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
]
}

View File

@@ -0,0 +1,21 @@
export type OptionId = string | number | symbol
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
imageSrc: string
name: string
metadata: string
}
export interface SortOption {
id: OptionId
name: string
sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[]
}
export interface FilterOption {
id: OptionId
name: string
}
export type LayoutMode = 'list' | 'grid' | 'list-small'

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { noop } from 'es-toolkit'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import LODFallback from '../../../components/LODFallback.vue'
defineProps<{
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name' | 'label'>
}>()
</script>
<template>
<div
class="flex items-center justify-between gap-2 h-[30px] overscroll-contain"
>
<div class="relative h-6 flex items-center mr-4">
<p
v-if="widget.name"
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20 lod-toggle"
>
{{ widget.label || widget.name }}
</p>
<LODFallback />
</div>
<div class="relative">
<div
class="w-75 cursor-default lod-toggle"
@pointerdown.stop="noop"
@pointermove.stop="noop"
@pointerup.stop="noop"
>
<slot />
</div>
<LODFallback />
</div>
</div>
</template>

View File

@@ -0,0 +1,16 @@
import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'bg-zinc-500/10',
// Outline
'border-none',
'outline',
'outline-1',
'outline-offset-[-1px]',
'outline-zinc-300/10',
// Rounded
'!rounded-lg',
// Hover
'hover:outline-blue-500/80'
])