mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
- rework save to not show dialog to save as, instead change to split button
- add tests
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
118
browser_tests/helpers/builderTestUtils.ts
Normal file
118
browser_tests/helpers/builderTestUtils.ts
Normal 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()
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
253
browser_tests/tests/builderSaveFlow.spec.ts
Normal file
253
browser_tests/tests/builderSaveFlow.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -89,6 +89,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
inputs: [...data.inputs],
|
||||
outputs: [...data.outputs]
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user