mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-04 21:22:07 +00:00
merge main into rh-test
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
])
|
||||
Reference in New Issue
Block a user