fix: call checkState after image input changes for proper undo tracking (#9623)

## Summary

Image input changes (dropdown selection and file upload) in app/linear
mode did not create their own undo entries, causing undo to skip or
bundle image changes with subsequent actions.

## Changes

- **What**: Add explicit `checkState()` calls in
`WidgetSelectDropdown.vue` after `modelValue` is set in
`updateSelectedItems()` (dropdown selection) and `handleFilesUpdate()`
(file upload), ensuring each image change gets its own undo entry.

## Review Focus

The fix is intentionally scoped to `WidgetSelectDropdown` rather than
the generic `updateHandler` in `NodeWidgets.vue`, which would create
excessive undo entries for text inputs. The pattern follows existing
usage in `useSelectedNodeActions.ts` and other composables.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9623-fix-call-checkState-after-image-input-changes-for-proper-undo-tracking-31d6d73d3650814781dbca5db459ab6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2026-03-11 19:44:16 -07:00
committed by GitHub
parent cccc0944a0
commit 0a62ea0b2c
2 changed files with 98 additions and 0 deletions

View File

@@ -15,7 +15,34 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import { createMockWidget } from './widgetTestUtils'
const mockCheckState = vi.hoisted(() => vi.fn())
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
'@/platform/workflow/management/stores/workflowStore'
)
return {
...actual,
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
}
}
})
}
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
@@ -456,3 +483,69 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void
handleFilesUpdate: (files: File[]) => Promise<void>
}
const mountForUndo = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<UndoTrackingInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
}) as unknown as VueWrapper<UndoTrackingInstance>
}
beforeEach(() => {
mockCheckState.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png', 'photo_abc.jpg'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
wrapper.vm.updateSelectedItems(new Set(['input-1']))
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('calls checkState after file upload completes', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
} as Response)
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
})

View File

@@ -17,6 +17,7 @@ import {
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type {
FilterOption,
@@ -376,6 +377,7 @@ function updateSelectedItems(selectedItems: Set<string>) {
return
}
modelValue.value = name
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
const uploadFile = async (
@@ -450,6 +452,9 @@ async function handleFilesUpdate(files: File[]) {
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
// 5. Snapshot undo state so the image change gets its own undo entry
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)