- rework save to not show dialog to save as, instead change to split button

- add tests
This commit is contained in:
pythongosssss
2026-03-25 07:23:38 -07:00
parent e87844aa10
commit d79b5db7fc
14 changed files with 525 additions and 250 deletions

View File

@@ -142,6 +142,29 @@ export class AppModeHelper {
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** The builder footer nav containing save/navigation buttons. */
private get builderFooterNav(): Locator {
return this.page
.getByRole('button', { name: 'Exit app builder' })
.locator('..')
}
/** Get a button in the builder footer by its accessible name. */
getFooterButton(name: string | RegExp): Locator {
return this.builderFooterNav.getByRole('button', { name })
}
/** Click the save/save-as button in the builder footer. */
async clickSave() {
await this.getFooterButton(/^Save/).first().click()
await this.comfyPage.nextFrame()
}
/** The "Opens as" popover tab above the builder footer. */
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.

View File

@@ -70,7 +70,8 @@ export const TestIds = {
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
widgetActionsMenu: 'widget-actions-menu',
opensAs: 'builder-opens-as'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'

View File

@@ -0,0 +1,118 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
/** Click the first SaveImage/PreviewImage node on the canvas. */
async function selectOutputNode(comfyPage: ComfyPage) {
const { page } = comfyPage
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
const { page } = comfyPage
await comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await comfyPage.nextFrame()
}
/**
* Enter builder on the default workflow and select I/O.
*
* Loads the default workflow, optionally transforms it (e.g. convert a node
* to subgraph), then enters builder mode and selects inputs + outputs.
*
* @param comfyPage - The page fixture.
* @param getInputNode - Returns the node to click for input selection.
* Receives the KSampler node ref and can transform the graph before
* returning the target node. Defaults to using KSampler directly.
* @returns The node used for input selection.
*/
export async function setupBuilder(
comfyPage: ComfyPage,
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
): Promise<NodeReference> {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
await selectInputWidget(comfyPage, inputNode)
await appMode.goToOutputs()
await selectOutputNode(comfyPage)
return inputNode
}
/**
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
*
* Returns the subgraph node reference for further interaction.
*/
export async function setupSubgraphBuilder(
comfyPage: ComfyPage
): Promise<NodeReference> {
return setupBuilder(comfyPage, async (ksampler) => {
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
expect(promotedNames).toContain('seed')
return subgraphNode
})
}
/** Save the workflow, reopen it, and enter app mode. */
export async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}

View File

@@ -1,89 +1,11 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '../helpers/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -0,0 +1,253 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
// The save-as dialog should appear with filename input and view type selection
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await expect(dialog.getByRole('textbox')).toBeVisible()
await expect(dialog.getByText('Save as')).toBeVisible()
// View type radio group should be present
const radioGroup = dialog.getByRole('radiogroup')
await expect(radioGroup).toBeVisible()
})
test('Save as dialog allows entering filename and saving', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-save-test`
const input = dialog.getByRole('textbox')
await input.fill(workflowName)
// Save button should be enabled now
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeEnabled()
await saveButton.click()
// Success dialog should appear
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
})
test('Save as dialog disables save when filename is empty', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Clear the filename input
const input = dialog.getByRole('textbox')
await input.fill('')
// Save button should be disabled
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeDisabled()
})
test('Builder step navigation works correctly', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Should start at outputs (we ended there in setup)
// Navigate to inputs
await appMode.goToInputs()
// Back button should be disabled on first step
const backButton = appMode.getFooterButton('Back')
await expect(backButton).toBeDisabled()
// Next button should be enabled
const nextButton = appMode.getFooterButton('Next')
await expect(nextButton).toBeEnabled()
// Navigate forward
await appMode.next()
// Back button should now be enabled
await expect(backButton).toBeEnabled()
// Navigate to preview (last step)
await appMode.next()
// Next button should be disabled on last step
await expect(nextButton).toBeDisabled()
})
test('Escape key exits builder mode', async ({ comfyPage }) => {
const { page } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Verify builder toolbar is visible
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
// Press Escape
await page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Builder toolbar should be gone
await expect(toolbar).not.toBeVisible()
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
await appMode.exitBuilder()
await expect(toolbar).not.toBeVisible()
})
test('Save button directly saves for previously saved workflow', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-direct-save`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Now click save again — should save directly and show success
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
})
test('Split button chevron opens save-as for saved workflow', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-split-btn`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Click the chevron dropdown trigger
const chevronButton = appMode.getFooterButton('Save as')
await chevronButton.click()
// "Save as" menu item should appear
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
await expect(menuItem).toBeVisible({ timeout: 5000 })
await menuItem.click()
// Save-as dialog should appear
const newSaveAsDialog = page.getByRole('dialog')
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
timeout: 5000
})
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// Without selecting any outputs, click the save button
// It should trigger the connect-output popover
await appMode.clickSave()
// The popover should show a message about connecting outputs
await expect(
page.getByText('Connect an output', { exact: false })
).toBeVisible({ timeout: 5000 })
})
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// App should be selected by default
const appRadio = dialog.getByRole('radio', { name: /App/ })
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
// Click Node graph option
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
await graphRadio.click()
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
})
})

View File

@@ -44,6 +44,7 @@ vi.mock('@/stores/dialogStore', () => ({
const mockActiveWorkflow = ref<{
isTemporary: boolean
initialMode?: string
isModified?: boolean
changeTracker?: { checkState: () => void }
} | null>({
isTemporary: true,
@@ -135,15 +136,6 @@ describe('BuilderFooterToolbar', () => {
}
}
function findSaveButton(wrapper: ReturnType<typeof mountComponent>) {
const nav = wrapper.find('nav')
const btn = nav
.findAll('button')
.find((b) => b.text().trim().startsWith('Save'))
if (!btn) throw new Error('Save button not found')
return btn
}
it('disables back on the first step', () => {
mockState.mode = 'builder:inputs'
const { back } = getNavButtons(mountComponent())
@@ -203,34 +195,38 @@ describe('BuilderFooterToolbar', () => {
it('shows "Save as" when workflow is temporary', () => {
mockActiveWorkflow.value = { isTemporary: true }
const save = findSaveButton(mountComponent())
expect(save.text()).toBe('Save as')
const wrapper = mountComponent()
expect(findButtonByText(wrapper, 'Save as')).toBeDefined()
})
it('shows "Save" when workflow is saved', () => {
mockActiveWorkflow.value = { isTemporary: false }
const save = findSaveButton(mountComponent())
expect(save.text()).toBe('Save')
const wrapper = mountComponent()
expect(findButtonByText(wrapper, 'Save')).toBeDefined()
})
it('calls saveAs when workflow is temporary', async () => {
mockActiveWorkflow.value = { isTemporary: true }
const save = findSaveButton(mountComponent())
await save.trigger('click')
await findButtonByText(mountComponent(), 'Save as').trigger('click')
expect(mockSaveAs).toHaveBeenCalledOnce()
})
it('calls save when workflow is saved', async () => {
mockActiveWorkflow.value = { isTemporary: false }
const save = findSaveButton(mountComponent())
await save.trigger('click')
it('calls save when workflow is saved and modified', async () => {
mockActiveWorkflow.value = { isTemporary: false, isModified: true }
await findButtonByText(mountComponent(), 'Save').trigger('click')
expect(mockSave).toHaveBeenCalledOnce()
})
it('disables save button when workflow has no unsaved changes', () => {
mockActiveWorkflow.value = { isTemporary: false, isModified: false }
const save = findButtonByText(mountComponent(), 'Save')
expect(save.attributes('disabled')).toBeDefined()
})
it('does not call save when no outputs', async () => {
mockHasOutputs.value = false
const save = findSaveButton(mountComponent())
await save.trigger('click')
const wrapper = mountComponent()
await findButtonByText(wrapper, 'Save as').trigger('click')
expect(mockSave).not.toHaveBeenCalled()
expect(mockSaveAs).not.toHaveBeenCalled()
})

View File

@@ -37,20 +37,58 @@
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<Button
size="lg"
class="bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground opacity-50 hover:bg-interface-builder-mode-button-background/80"
>
{{ saveButtonLabel }}
<Button size="lg" :class="cn('w-24', disabledSaveClasses)">
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<Button
v-else
size="lg"
class="bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/80"
@click="isSaved ? save() : saveAs()"
<ButtonGroup
v-else-if="isSaved"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
{{ saveButtonLabel }}
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
{{ t('builderToolbar.saveAs') }}
</Button>
</nav>
</div>
@@ -60,13 +98,22 @@
import { computed } from 'vue'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
import { setWorkflowDefaultView } from './builderViewOptions'
@@ -92,16 +139,23 @@ const {
})
const { save, saveAs } = useBuilderSave()
const isSaved = computed(() => !workflowStore.activeWorkflow?.isTemporary)
const isSaved = computed(
() => workflowStore.activeWorkflow?.isTemporary === false
)
const activeSaveClasses =
'bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/80'
const disabledSaveClasses =
'bg-secondary-background text-muted-foreground/50 disabled:opacity-100'
const isModified = computed(
() => workflowStore.activeWorkflow?.isModified === true
)
const isAppMode = computed(
() => workflowStore.activeWorkflow?.initialMode !== 'graph'
)
const saveButtonLabel = computed(() =>
isSaved.value ? t('g.save') : t('builderToolbar.saveAs')
)
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
e.key === 'Escape' &&

View File

@@ -2,6 +2,7 @@
<PopoverRoot>
<PopoverAnchor as-child>
<div
data-testid="builder-opens-as"
class="flex h-8 min-w-64 items-center justify-center gap-2 rounded-t-2xl bg-interface-builder-mode-footer-background px-4 text-sm text-interface-builder-mode-button-foreground"
>
<i :class="cn(currentModeIcon, 'size-4')" aria-hidden="true" />

View File

@@ -1,35 +0,0 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
{{ $t('builderSave.confirmTitle') }}
</template>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderSave.confirmBody') }}
</p>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button variant="textonly" size="lg" @click="$emit('saveAsNew')">
{{ $t('builderSave.saveAsNew') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('save')">
{{ $t('g.save') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineEmits<{
close: []
saveAsNew: []
save: []
}>()
</script>

View File

@@ -73,15 +73,10 @@ vi.mock('@/i18n', () => ({
}
}))
vi.mock('./BuilderSaveConfirmDialogContent.vue', () => ({
default: { template: '<div />' }
}))
vi.mock('./BuilderSaveDialogContent.vue', () => ({
default: { template: '<div />' }
}))
const CONFIRM_DIALOG_KEY = 'builder-save-confirm'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
@@ -101,83 +96,35 @@ describe('useBuilderSave', () => {
mockActiveWorkflow.value = null
const { save } = await importComposable()
save()
await save()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
expect(mockSaveWorkflow).not.toHaveBeenCalled()
})
it('opens confirm dialog with correct key and callbacks', async () => {
it('saves workflow directly and shows success dialog', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { save } = await importComposable()
save()
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
expect(key).toBe(CONFIRM_DIALOG_KEY)
expect(typeof props.onSave).toBe('function')
expect(typeof props.onSaveAsNew).toBe('function')
expect(typeof props.onClose).toBe('function')
})
})
describe('confirm dialog callbacks', () => {
async function getConfirmDialogProps() {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { save } = await importComposable()
save()
return mockShowLayoutDialog.mock.calls[0][0].props as {
onSave: () => Promise<void>
onSaveAsNew: () => void
onClose: () => void
}
}
it('onSave calls saveWorkflow and shows success dialog on success', async () => {
mockSaveWorkflow.mockResolvedValueOnce(undefined)
const { onSave } = await getConfirmDialogProps()
const { save } = await importComposable()
await onSave()
await save()
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: CONFIRM_DIALOG_KEY })
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
expect(successCall.props.promptText).toBe('builderSave.successBody')
expect(successCall.footerProps.confirmText).toBeDefined()
})
it('onSave toasts error and closes confirm dialog on failure', async () => {
it('toasts error on failure', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const error = new Error('save failed')
mockSaveWorkflow.mockRejectedValueOnce(error)
const { onSave } = await getConfirmDialogProps()
const { save } = await importComposable()
await onSave()
await save()
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
expect(mockCloseDialog).toHaveBeenCalledWith({ key: CONFIRM_DIALOG_KEY })
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
})
it('onSaveAsNew closes confirm dialog and opens save-as dialog', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { onSaveAsNew } = await getConfirmDialogProps()
onSaveAsNew()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: CONFIRM_DIALOG_KEY })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(2)
const saveAsCall = mockShowLayoutDialog.mock.calls[1][0]
expect(saveAsCall.key).toBe(SAVE_DIALOG_KEY)
})
it('onClose closes confirm dialog', async () => {
const { onClose } = await getConfirmDialogProps()
onClose()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: CONFIRM_DIALOG_KEY })
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
})
})
@@ -277,7 +224,6 @@ describe('useBuilderSave', () => {
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.props.promptText).toBe('builderSave.successBodyApp')
expect(successCall.footerProps.confirmText).toBeDefined()
})
it('shows graph success message with exit builder button when openAsApp is false', async () => {
@@ -311,11 +257,7 @@ describe('useBuilderSave', () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
mockSaveWorkflow.mockResolvedValueOnce(undefined)
const { save } = await importComposable()
save()
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
onSave: () => Promise<void>
}
await onSave()
await save()
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
onConfirm: () => void
onCancel: () => void

View File

@@ -10,11 +10,9 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { setWorkflowDefaultView } from './builderViewOptions'
import BuilderSaveConfirmDialogContent from './BuilderSaveConfirmDialogContent.vue'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const CONFIRM_DIALOG_KEY = 'builder-save-confirm'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
@@ -30,25 +28,7 @@ export function useBuilderSave() {
dialogStore.closeDialog({ key })
}
function save() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
dialogService.showLayoutDialog({
key: CONFIRM_DIALOG_KEY,
component: BuilderSaveConfirmDialogContent,
props: {
onSave: () => handleConfirmSave(),
onSaveAsNew: () => {
closeDialog(CONFIRM_DIALOG_KEY)
saveAs()
},
onClose: () => closeDialog(CONFIRM_DIALOG_KEY)
}
})
}
async function handleConfirmSave() {
async function save() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
@@ -57,8 +37,6 @@ export function useBuilderSave() {
showSuccessDialog()
} catch (e) {
toastErrorHandler(e)
} finally {
closeDialog(CONFIRM_DIALOG_KEY)
}
}

View File

@@ -3690,9 +3690,6 @@
"opensAsGraph": "Open as a {mode}"
},
"builderSave": {
"confirmTitle": "Save your changes?",
"confirmBody": "Save changes to this app, or save as a new app to keep both versions.",
"saveAsNew": "Save as new",
"successTitle": "Successfully saved",
"successBody": "Would you like to view it now?",
"successBodyApp": "This workflow will open in App Mode by default from now on.\n\nWould you like to view it now?",

View File

@@ -332,6 +332,30 @@ describe('appModeStore', () => {
})
})
it('calls checkState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
await nextTick()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('calls checkState when input is deselected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
store.selectedInputs.push([42, 'prompt'])
await nextTick()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.splice(0, 1)
await nextTick()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('reflects input changes in linearData', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()

View File

@@ -89,6 +89,7 @@ export const useAppModeStore = defineStore('appMode', () => {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
workflowStore.activeWorkflow?.changeTracker?.checkState()
},
{ deep: true }
)