mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 13:10:24 +00:00
feat: Show empty workflow dialog when entering app builder with no nodes (#9379)
## Summary Prompts users to load a template or return to graph when entering builder mode on an empty workflow ## Screenshots (if applicable) <img width="627" height="275" alt="image" src="https://github.com/user-attachments/assets/c1a35dc3-4e8f-4abd-95b9-2f92524e8ebf" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9379-feat-Show-empty-workflow-dialog-when-entering-app-builder-with-no-nodes-3196d73d36508123b643ec893cd86cac) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showClose"
|
||||
variant="muted-textonly"
|
||||
class="-mr-1"
|
||||
:aria-label="$t('g.close')"
|
||||
@@ -37,6 +38,10 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { showClose = true } = defineProps<{
|
||||
showClose?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
40
src/components/builder/EmptyWorkflowDialogContent.vue
Normal file
40
src/components/builder/EmptyWorkflowDialogContent.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<BuilderDialog :show-close="false">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.emptyWorkflowTitle') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
@click="$emit('backToWorkflow')"
|
||||
>
|
||||
{{ $t('builderToolbar.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
|
||||
{{ $t('builderToolbar.loadTemplate') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineEmits<{
|
||||
backToWorkflow: []
|
||||
loadTemplate: []
|
||||
}>()
|
||||
</script>
|
||||
44
src/components/builder/useEmptyWorkflowDialog.ts
Normal file
44
src/components/builder/useEmptyWorkflowDialog.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import EmptyWorkflowDialogContent from './EmptyWorkflowDialogContent.vue'
|
||||
|
||||
const DIALOG_KEY = 'builder-empty-workflow'
|
||||
|
||||
export function useEmptyWorkflowDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
|
||||
function show(options: {
|
||||
onEnterBuilder: () => void
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: EmptyWorkflowDialogContent,
|
||||
props: {
|
||||
onBackToWorkflow: () => {
|
||||
closeDialog()
|
||||
options.onDismiss()
|
||||
},
|
||||
onLoadTemplate: () => {
|
||||
closeDialog()
|
||||
templateSelectorDialog.show('appbuilder', {
|
||||
afterClose: () => {
|
||||
if (app.rootGraph?.nodes?.length) options.onEnterBuilder()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
return { show }
|
||||
}
|
||||
@@ -107,6 +107,32 @@ describe('useWorkflowTemplateSelectorDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('invokes afterClose callback when dialog is closed', () => {
|
||||
mockNewUserService.isNewUser.mockReturnValue(false)
|
||||
const afterClose = vi.fn()
|
||||
|
||||
const dialog = useWorkflowTemplateSelectorDialog()
|
||||
dialog.show('command', { afterClose })
|
||||
|
||||
const onClose =
|
||||
mockDialogService.showLayoutDialog.mock.calls[0][0].props.onClose
|
||||
onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
|
||||
expect(afterClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail when afterClose is not provided', () => {
|
||||
mockNewUserService.isNewUser.mockReturnValue(false)
|
||||
|
||||
const dialog = useWorkflowTemplateSelectorDialog()
|
||||
dialog.show('command')
|
||||
|
||||
const onClose =
|
||||
mockDialogService.showLayoutDialog.mock.calls[0][0].props.onClose
|
||||
expect(() => onClose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('tracks telemetry with source', () => {
|
||||
mockNewUserService.isNewUser.mockReturnValue(false)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateLibraryMetadata } from '@/platform/telemetry/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -17,8 +18,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
}
|
||||
|
||||
function show(
|
||||
source: 'sidebar' | 'menu' | 'command' = 'command',
|
||||
options?: { initialCategory?: string }
|
||||
source: TemplateLibraryMetadata['source'] = 'command',
|
||||
options?: { initialCategory?: string; afterClose?: () => void }
|
||||
) {
|
||||
useTelemetry()?.trackTemplateLibraryOpened({ source })
|
||||
|
||||
@@ -30,7 +31,10 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
key: DIALOG_KEY,
|
||||
component: WorkflowTemplateSelectorDialog,
|
||||
props: {
|
||||
onClose: hide,
|
||||
onClose: () => {
|
||||
hide()
|
||||
options?.afterClose?.()
|
||||
},
|
||||
initialCategory
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2593,7 +2593,7 @@
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
"enterBuilderMode": "Enter app builder",
|
||||
"enterBuilderMode": "App builder",
|
||||
"workflowActions": "Workflow actions",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
@@ -3379,7 +3379,12 @@
|
||||
"defaultModeAppliedAppPrompt": "Would you like to view it now?",
|
||||
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
|
||||
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
|
||||
"viewApp": "View app"
|
||||
"viewApp": "View app",
|
||||
"emptyWorkflowTitle": "This workflow has no nodes",
|
||||
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
|
||||
"emptyWorkflowPrompt": "Do you want to start with a template?",
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
|
||||
@@ -158,7 +158,7 @@ export type WorkflowOpenSource = NonNullable<
|
||||
* Template library metadata
|
||||
*/
|
||||
export interface TemplateLibraryMetadata {
|
||||
source: 'sidebar' | 'menu' | 'command'
|
||||
source: 'sidebar' | 'menu' | 'command' | 'appbuilder'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,17 +3,29 @@ import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
|
||||
return {
|
||||
show: vi.fn((options: typeof lastOptions) => {
|
||||
lastOptions = options
|
||||
}),
|
||||
get lastOptions() {
|
||||
return lastOptions
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { extra: {} }
|
||||
rootGraph: { extra: {}, nodes: [{ id: 1 }] }
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -31,6 +43,10 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
|
||||
}))
|
||||
|
||||
import { useAppModeStore } from './appModeStore'
|
||||
|
||||
function createBuilderWorkflow(
|
||||
@@ -49,19 +65,22 @@ function createBuilderWorkflow(
|
||||
}
|
||||
|
||||
describe('appModeStore', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let store: ReturnType<typeof useAppModeStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('enterBuilder', () => {
|
||||
it('navigates to builder:arrange when in app mode with outputs', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('app')
|
||||
|
||||
const store = useAppModeStore()
|
||||
store.selectedOutputs.push(1)
|
||||
|
||||
store.enterBuilder()
|
||||
@@ -70,21 +89,15 @@ describe('appModeStore', () => {
|
||||
})
|
||||
|
||||
it('navigates to builder:select when in app mode without outputs', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('app')
|
||||
|
||||
const store = useAppModeStore()
|
||||
|
||||
store.enterBuilder()
|
||||
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
|
||||
})
|
||||
|
||||
it('navigates to builder:select when in graph mode with outputs', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
const store = useAppModeStore()
|
||||
store.selectedOutputs.push(1)
|
||||
|
||||
store.enterBuilder()
|
||||
@@ -93,15 +106,67 @@ describe('appModeStore', () => {
|
||||
})
|
||||
|
||||
it('navigates to builder:select when in graph mode without outputs', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
const store = useAppModeStore()
|
||||
|
||||
store.enterBuilder()
|
||||
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
|
||||
})
|
||||
|
||||
it('shows empty workflow dialog when graph has no nodes', () => {
|
||||
vi.mocked(app.rootGraph).nodes = []
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
|
||||
expect(mockEmptyWorkflowDialog.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onEnterBuilder: expect.any(Function),
|
||||
onDismiss: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty workflow dialog callbacks', () => {
|
||||
function getDialogOptions() {
|
||||
vi.mocked(app.rootGraph).nodes = []
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
store.enterBuilder()
|
||||
return mockEmptyWorkflowDialog.lastOptions
|
||||
}
|
||||
|
||||
it('onDismiss sets graph mode', () => {
|
||||
const options = getDialogOptions()
|
||||
|
||||
// Move to builder so onDismiss must actually transition back
|
||||
workflowStore.activeWorkflow!.activeMode = 'builder:select'
|
||||
|
||||
options.onDismiss()
|
||||
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('onEnterBuilder enters builder when nodes exist', () => {
|
||||
const options = getDialogOptions()
|
||||
|
||||
// Simulate template having loaded nodes
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
|
||||
options.onEnterBuilder()
|
||||
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
|
||||
})
|
||||
|
||||
it('onEnterBuilder shows dialog again when no nodes', () => {
|
||||
const options = getDialogOptions()
|
||||
|
||||
mockEmptyWorkflowDialog.show.mockClear()
|
||||
options.onEnterBuilder()
|
||||
|
||||
expect(mockEmptyWorkflowDialog.show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSelections pruning', () => {
|
||||
@@ -135,9 +200,6 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(
|
||||
[
|
||||
[1, 'prompt'],
|
||||
@@ -156,9 +218,6 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData(
|
||||
[
|
||||
[1, 'prompt'],
|
||||
@@ -180,9 +239,6 @@ describe('appModeStore', () => {
|
||||
id == 1 ? (node1 as unknown as LGraphNode) : undefined
|
||||
)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
|
||||
await nextTick()
|
||||
|
||||
@@ -192,9 +248,6 @@ describe('appModeStore', () => {
|
||||
it('hasOutputs is false when all output nodes are deleted', async () => {
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
|
||||
await nextTick()
|
||||
|
||||
@@ -205,9 +258,6 @@ describe('appModeStore', () => {
|
||||
|
||||
describe('linearData sync watcher', () => {
|
||||
it('writes linearData to rootGraph.extra when in builder mode', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
await nextTick()
|
||||
|
||||
@@ -221,12 +271,7 @@ describe('appModeStore', () => {
|
||||
})
|
||||
|
||||
it('does not write linearData when not in builder mode', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflow.activeMode = 'graph'
|
||||
workflowStore.activeWorkflow = workflow
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
await nextTick()
|
||||
|
||||
store.selectedOutputs.push(1)
|
||||
@@ -236,9 +281,6 @@ describe('appModeStore', () => {
|
||||
})
|
||||
|
||||
it('does not write when rootGraph is null', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
await nextTick()
|
||||
|
||||
@@ -255,9 +297,6 @@ describe('appModeStore', () => {
|
||||
})
|
||||
|
||||
it('reflects input changes in linearData', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useAppModeStore()
|
||||
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
|
||||
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
@@ -13,6 +14,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { mode, setMode, isBuilderMode } = useAppMode()
|
||||
const emptyWorkflowDialog = useEmptyWorkflowDialog()
|
||||
|
||||
const selectedInputs = reactive<[NodeId, string][]>([])
|
||||
const selectedOutputs = reactive<NodeId[]>([])
|
||||
@@ -92,6 +94,14 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
)
|
||||
|
||||
function enterBuilder() {
|
||||
if (!app.rootGraph?.nodes?.length) {
|
||||
emptyWorkflowDialog.show({
|
||||
onEnterBuilder: () => enterBuilder(),
|
||||
onDismiss: () => setMode('graph')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setMode(
|
||||
mode.value === 'app' && hasOutputs.value
|
||||
? 'builder:arrange'
|
||||
|
||||
Reference in New Issue
Block a user