fix: File Upload widget disabled prop treats undefined as true (#5528)

* fix file upload widget disabled prop

* [test] extract createMockWidget to shared test utility - addresses @DrJKL's code replication concern

Creates testUtils.ts with shared createMockWidget and createMockFile functions
to reduce duplication across widget component tests. This ensures consistency
and maintainability of test setup code.

* [test] replace type assertions with type narrowing - addresses @DrJKL's type safety suggestion

Replaces unsafe `as HTMLInputElement` casts with proper instanceof checks
and error throwing. Also refactors File Type Detection tests to use it.for
instead of conditionals to eliminate anti-pattern.

* [feat] use destructuring with default value for readonly prop - addresses @DrJKL's Vue best practice suggestion

Replace manual fallback expressions like `readonly || false` with modern Vue 3
destructuring pattern: `const { readonly = false } = defineProps()`.
This is cleaner than withDefaults() and follows current Vue best practices.

* [test] improve test utilities usage - addresses @DrJKL's additional suggestions

- Replace findComponent with getComponent for better error handling
- Use optional chaining (?.()) instead of conditional checks for cleaner syntax
- Remove unnecessary existence checks since getComponent throws on failure
This commit is contained in:
Christian Byrne
2025-09-14 00:20:30 -07:00
committed by GitHub
parent d146a7896a
commit c7325c4da9
3 changed files with 629 additions and 4 deletions

View File

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

View File

@@ -187,7 +187,11 @@ import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
modelValue,
readonly = false
} = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
@@ -198,8 +202,8 @@ const emit = defineEmits<{
}>()
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
widget,
modelValue,
defaultValue: null,
emit
})
@@ -280,7 +284,7 @@ const triggerFileInput = () => {
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (!props.readonly && target.files && target.files.length > 0) {
if (!readonly && target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]

View File

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