fix: Use generic type in multi-select widget and fix options binding (#5525)

* use generic type and fix options binding

* [refactor] improve type safety in WidgetMultiSelect - addresses review comments

- Simplify array check to use Array.isArray(options?.values)
- Add generic type parameter to useWidgetValue call
- Remove unnecessary type assertion by leveraging TypeScript inference
This commit is contained in:
Christian Byrne
2025-09-13 21:45:49 -07:00
committed by GitHub
parent f28ebcac19
commit 8804755ffa
2 changed files with 378 additions and 6 deletions

View File

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

View File

@@ -2,6 +2,7 @@
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
:options="multiSelectOptions"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
@@ -15,13 +16,13 @@
</WidgetLayoutField>
</template>
<script setup lang="ts">
<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 } from '@/types/simplifiedWidget'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
@@ -30,17 +31,17 @@ import {
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<any[]>
modelValue: any[]
widget: SimplifiedWidget<T[]>
modelValue: T[]
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
'update:modelValue': [value: T[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
const { localValue, onChange } = useWidgetValue<T[]>({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
@@ -60,4 +61,15 @@ 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>