mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
rework save flow
- add mode selection tab on footer toolbar - extract reusable radio group component - remove setting default mode dialog - add save/save as/saved dialogs
This commit is contained in:
@@ -230,6 +230,7 @@
|
||||
--interface-builder-mode-background: var(--color-ocean-300);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
--interface-builder-mode-footer-background: var(--color-ocean-900);
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
@@ -373,6 +374,7 @@
|
||||
--interface-builder-mode-background: var(--color-ocean-900);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
--interface-builder-mode-footer-background: var(--color-ocean-900);
|
||||
|
||||
--nav-background: var(--color-charcoal-800);
|
||||
|
||||
@@ -516,6 +518,9 @@
|
||||
--color-interface-builder-mode-button-foreground: var(
|
||||
--interface-builder-mode-button-foreground
|
||||
);
|
||||
--color-interface-builder-mode-footer-background: var(
|
||||
--interface-builder-mode-footer-background
|
||||
);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-check-big] size-4 text-green-500"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppBody')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphBody')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppPrompt')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="appliedAsApp">
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
|
||||
{{ $t('builderToolbar.exitToWorkflow') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
appliedAsApp: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
viewApp: []
|
||||
close: []
|
||||
exitToWorkflow: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
|
||||
>
|
||||
<div class="flex w-full min-w-116 flex-col rounded-2xl bg-base-background">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
|
||||
@@ -10,11 +10,11 @@ import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
const mockSave = vi.hoisted(() => vi.fn())
|
||||
const mockSaveAs = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockState = {
|
||||
mode: 'builder:select' as AppMode,
|
||||
settingView: false
|
||||
mode: 'builder:inputs' as AppMode
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
@@ -41,10 +41,35 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
|
||||
useAppSetDefaultView: () => ({
|
||||
settingView: computed(() => mockState.settingView),
|
||||
showDialog: mockShowDialog
|
||||
const mockActiveWorkflow = ref<{
|
||||
isTemporary: boolean
|
||||
initialMode?: string
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null>({
|
||||
isTemporary: true,
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./useBuilderSave', () => ({
|
||||
useBuilderSave: () => ({
|
||||
save: mockSave,
|
||||
saveAs: mockSaveAs
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -54,7 +79,17 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
builderMenu: { exitAppBuilder: 'Exit app builder' },
|
||||
g: { back: 'Back', next: 'Next' }
|
||||
builderToolbar: {
|
||||
viewApp: 'View app',
|
||||
saveAs: 'Save as',
|
||||
app: 'App',
|
||||
nodeGraph: 'Node graph'
|
||||
},
|
||||
builderFooter: {
|
||||
opensAsApp: 'Open as an {mode}',
|
||||
opensAsGraph: 'Open as a {mode}'
|
||||
},
|
||||
g: { back: 'Back', next: 'Next', save: 'Save' }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -65,83 +100,138 @@ describe('BuilderFooterToolbar', () => {
|
||||
vi.clearAllMocks()
|
||||
mockState.mode = 'builder:inputs'
|
||||
mockHasOutputs.value = true
|
||||
mockState.settingView = false
|
||||
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
return mount(BuilderFooterToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: false }
|
||||
stubs: {
|
||||
Button: false,
|
||||
BuilderOpensAsPopover: true,
|
||||
ConnectOutputPopover: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
|
||||
const buttons = wrapper.findAll('button')
|
||||
function findButtonByText(
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
text: string
|
||||
) {
|
||||
const nav = wrapper.find('nav')
|
||||
const btn = nav.findAll('button').find((b) => b.text().trim() === text)
|
||||
if (!btn) throw new Error(`Button "${text}" not found`)
|
||||
return btn
|
||||
}
|
||||
|
||||
function getNavButtons(wrapper: ReturnType<typeof mountComponent>) {
|
||||
return {
|
||||
exit: buttons[0],
|
||||
back: buttons[1],
|
||||
next: buttons[2]
|
||||
exit: findButtonByText(wrapper, 'Exit app builder'),
|
||||
viewApp: findButtonByText(wrapper, 'View app'),
|
||||
back: findButtonByText(wrapper, 'Back'),
|
||||
next: findButtonByText(wrapper, 'Next')
|
||||
}
|
||||
}
|
||||
|
||||
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 } = getButtons(mountComponent())
|
||||
const { back } = getNavButtons(mountComponent())
|
||||
expect(back.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables back on the second step', () => {
|
||||
it('enables back on the arrange step', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { back } = getButtons(mountComponent())
|
||||
const { back } = getNavButtons(mountComponent())
|
||||
expect(back.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables next on the setDefaultView step', () => {
|
||||
mockState.settingView = true
|
||||
const { next } = getButtons(mountComponent())
|
||||
it('disables next on arrange step (last step)', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { next } = getNavButtons(mountComponent())
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables next on arrange step when no outputs', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
mockHasOutputs.value = false
|
||||
const { next } = getButtons(mountComponent())
|
||||
const { next } = getNavButtons(mountComponent())
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables next on inputs step', () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { next } = getButtons(mountComponent())
|
||||
const { next } = getNavButtons(mountComponent())
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls setMode on back click', async () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { back } = getButtons(mountComponent())
|
||||
const { back } = getNavButtons(mountComponent())
|
||||
await back.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('calls setMode on next click from inputs step', async () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { next } = getButtons(mountComponent())
|
||||
const { next } = getNavButtons(mountComponent())
|
||||
await next.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('opens default view dialog on next click from arrange step', async () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { next } = getButtons(mountComponent())
|
||||
await next.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
|
||||
expect(mockShowDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls exitBuilder on exit button click', async () => {
|
||||
const { exit } = getButtons(mountComponent())
|
||||
const { exit } = getNavButtons(mountComponent())
|
||||
await exit.trigger('click')
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls setMode app on view app click', async () => {
|
||||
const { viewApp } = getNavButtons(mountComponent())
|
||||
await viewApp.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('shows "Save as" when workflow is temporary', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: true }
|
||||
const save = findSaveButton(mountComponent())
|
||||
expect(save.text()).toBe('Save as')
|
||||
})
|
||||
|
||||
it('shows "Save" when workflow is saved', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false }
|
||||
const save = findSaveButton(mountComponent())
|
||||
expect(save.text()).toBe('Save')
|
||||
})
|
||||
|
||||
it('calls saveAs when workflow is temporary', async () => {
|
||||
mockActiveWorkflow.value = { isTemporary: true }
|
||||
const save = findSaveButton(mountComponent())
|
||||
await save.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')
|
||||
expect(mockSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call save when no outputs', async () => {
|
||||
mockHasOutputs.value = false
|
||||
const save = findSaveButton(mountComponent())
|
||||
await save.trigger('click')
|
||||
expect(mockSave).not.toHaveBeenCalled()
|
||||
expect(mockSaveAs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,106 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 flex-col items-center"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
<!-- "Opens as" attachment tab -->
|
||||
<BuilderOpensAsPopover
|
||||
v-if="isSaved"
|
||||
:is-app-mode="isAppMode"
|
||||
@select="onSetDefaultView"
|
||||
/>
|
||||
|
||||
<!-- Main toolbar -->
|
||||
<nav
|
||||
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
</nav>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="onViewApp">
|
||||
{{ t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
: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>
|
||||
</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()"
|
||||
>
|
||||
{{ saveButtonLabel }}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.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 BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
|
||||
const {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
goBack,
|
||||
goNext
|
||||
} = useBuilderSteps({
|
||||
hasOutputs
|
||||
})
|
||||
const { save, saveAs } = useBuilderSave()
|
||||
|
||||
const isSaved = computed(() => !workflowStore.activeWorkflow?.isTemporary)
|
||||
|
||||
const isAppMode = computed(
|
||||
() => workflowStore.activeWorkflow?.initialMode !== 'graph'
|
||||
)
|
||||
|
||||
const saveButtonLabel = computed(() =>
|
||||
isSaved.value ? t('g.save') : t('builderToolbar.saveAs')
|
||||
)
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -60,4 +120,14 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
function onExitBuilder() {
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
|
||||
function onViewApp() {
|
||||
setMode('app')
|
||||
}
|
||||
|
||||
function onSetDefaultView(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
setWorkflowDefaultView(workflow, openAsApp)
|
||||
}
|
||||
</script>
|
||||
|
||||
83
src/components/builder/BuilderOpensAsPopover.vue
Normal file
83
src/components/builder/BuilderOpensAsPopover.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<PopoverRoot>
|
||||
<PopoverAnchor as-child>
|
||||
<div
|
||||
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" />
|
||||
<i18n-t
|
||||
:keypath="
|
||||
isAppMode
|
||||
? 'builderFooter.opensAsApp'
|
||||
: 'builderFooter.opensAsGraph'
|
||||
"
|
||||
tag="span"
|
||||
>
|
||||
<template #mode>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
class="-ml-0.5 h-6 gap-1 rounded-md border-none bg-transparent px-1.5 text-sm text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/70"
|
||||
>
|
||||
{{
|
||||
isAppMode
|
||||
? t('builderToolbar.app').toLowerCase()
|
||||
: t('builderToolbar.nodeGraph').toLowerCase()
|
||||
}}
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<ViewTypeRadioGroup
|
||||
:model-value="isAppMode"
|
||||
:aria-label="t('builderToolbar.defaultViewLabel')"
|
||||
size="sm"
|
||||
@update:model-value="$emit('select', $event)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
|
||||
|
||||
const { isAppMode } = defineProps<{
|
||||
isAppMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [openAsApp: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentModeIcon = computed(() =>
|
||||
isAppMode ? 'icon-[lucide--app-window]' : 'icon-[comfy--workflow]'
|
||||
)
|
||||
</script>
|
||||
35
src/components/builder/BuilderSaveConfirmDialogContent.vue
Normal file
35
src/components/builder/BuilderSaveConfirmDialogContent.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
71
src/components/builder/BuilderSaveDialogContent.vue
Normal file
71
src/components/builder/BuilderSaveDialogContent.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<BuilderDialog @close="emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :for="inputId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.filename') }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="filename"
|
||||
autofocus
|
||||
type="text"
|
||||
class="focus-visible:ring-ring flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground"
|
||||
@keydown.enter="
|
||||
filename.trim() && emit('save', filename.trim(), openAsApp)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :id="radioGroupLabelId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<ViewTypeRadioGroup
|
||||
v-model="openAsApp"
|
||||
:aria-labelledby="radioGroupLabelId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="emit('save', filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
|
||||
|
||||
const { defaultFilename, defaultOpenAsApp = true } = defineProps<{
|
||||
defaultFilename: string
|
||||
defaultOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [filename: string, openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const radioGroupLabelId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(defaultOpenAsApp)
|
||||
</script>
|
||||
@@ -23,55 +23,21 @@
|
||||
<StepLabel :step />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Default view -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'setDefaultView'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'bg-transparent hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="navigateToStep('setDefaultView')"
|
||||
>
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="mx-1 h-px w-4 bg-border-default"
|
||||
role="separator"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
@@ -79,9 +45,7 @@ import type { BuilderStepId } from './useBuilderSteps'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
|
||||
const { activeStep, navigateToStep } = useBuilderSteps()
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
@@ -107,11 +71,5 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'setDefaultView',
|
||||
title: t('builderToolbar.defaultView'),
|
||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||
icon: 'icon-[lucide--eye]'
|
||||
}
|
||||
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="8"
|
||||
align="end"
|
||||
:side-offset="18"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
|
||||
>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.defaultViewTitle') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
:class="
|
||||
cn(
|
||||
itemClasses,
|
||||
openAsApp === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="openAsApp = option.value"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="openAsApp === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
|
||||
{{ $t('g.apply') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { initialOpenAsApp = true } = defineProps<{
|
||||
initialOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
apply: [openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const openAsApp = ref(initialOpenAsApp)
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
|
||||
const itemClasses =
|
||||
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
|
||||
</script>
|
||||
74
src/components/builder/ViewTypeRadioGroup.vue
Normal file
74
src/components/builder/ViewTypeRadioGroup.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div role="radiogroup" v-bind="$attrs" :class="cn('flex flex-col', gapClass)">
|
||||
<Button
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="modelValue === option.value"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background',
|
||||
heightClass,
|
||||
modelValue === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="
|
||||
modelValue !== option.value && emit('update:modelValue', option.value)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="modelValue === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { size = 'md' } = defineProps<{
|
||||
modelValue: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
const heightClass = size === 'sm' ? 'h-12' : 'h-14'
|
||||
const gapClass = size === 'sm' ? 'gap-1' : 'gap-2'
|
||||
</script>
|
||||
70
src/components/builder/builderViewOptions.test.ts
Normal file
70
src/components/builder/builderViewOptions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMockLoadedWorkflow } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import type { setWorkflowDefaultView as SetWorkflowDefaultViewFn } from './builderViewOptions'
|
||||
|
||||
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackDefaultViewSet: mockTrackDefaultViewSet })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
describe('setWorkflowDefaultView', () => {
|
||||
let setWorkflowDefaultView: typeof SetWorkflowDefaultViewFn
|
||||
let app: { rootGraph: { extra: Record<string, unknown> } }
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./builderViewOptions')
|
||||
setWorkflowDefaultView = mod.setWorkflowDefaultView
|
||||
app = (await import('@/scripts/app')).app as typeof app
|
||||
app.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(app.rootGraph.extra.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls changeTracker.checkState', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks telemetry with correct default_view', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'app'
|
||||
})
|
||||
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'graph'
|
||||
})
|
||||
})
|
||||
})
|
||||
16
src/components/builder/builderViewOptions.ts
Normal file
16
src/components/builder/builderViewOptions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function setWorkflowDefaultView(
|
||||
workflow: LoadedComfyWorkflow,
|
||||
openAsApp: boolean
|
||||
) {
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn(),
|
||||
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: null as {
|
||||
initialMode?: string | null
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
rootGraph: { extra: {} as Record<string, unknown> }
|
||||
}))
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
exitBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => mockAppModeStore
|
||||
}))
|
||||
|
||||
vi.mock('./DefaultViewDialogContent.vue', () => ({
|
||||
default: { name: 'MockDefaultViewDialogContent' }
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
|
||||
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
|
||||
}))
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
describe('useAppSetDefaultView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockApp.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
describe('settingView', () => {
|
||||
it('reflects dialogStore.isDialogOpen', () => {
|
||||
mockDialogStore.isDialogOpen.mockReturnValue(true)
|
||||
const { settingView } = useAppSetDefaultView()
|
||||
expect(settingView.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDialog', () => {
|
||||
it('opens dialog via dialogService', () => {
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when initialMode is not graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp false when initialMode is graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(false)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when no active workflow', () => {
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleApply', () => {
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('closes dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.key).toBe('builder-default-view-applied')
|
||||
expect(confirmCall.props.appliedAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes appliedAsApp false to confirmation dialog when graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.props.appliedAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applied dialog', () => {
|
||||
function applyAndGetConfirmDialog(openAsApp: boolean) {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
applyCall.props.onApply(openAsApp)
|
||||
|
||||
return mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
}
|
||||
|
||||
it('onViewApp sets mode to app and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onViewApp()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onExitToWorkflow exits builder and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onExitToWorkflow()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onClose closes confirmation dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
|
||||
mockDialogStore.closeDialog.mockClear()
|
||||
confirmCall.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
|
||||
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const DIALOG_KEY = 'builder-default-view'
|
||||
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
|
||||
|
||||
export function useAppSetDefaultView() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
|
||||
|
||||
function showDialog() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: DefaultViewDialogContent,
|
||||
props: {
|
||||
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
|
||||
onApply: handleApply,
|
||||
onClose: closeDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleApply(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
function showAppliedDialog(appliedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: APPLIED_DIALOG_KEY,
|
||||
component: BuilderDefaultModeAppliedDialogContent,
|
||||
props: {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
closeAppliedDialog()
|
||||
appModeStore.exitBuilder()
|
||||
},
|
||||
onClose: closeAppliedDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function closeAppliedDialog() {
|
||||
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
|
||||
}
|
||||
|
||||
return { settingView, showDialog }
|
||||
}
|
||||
383
src/components/builder/useBuilderSave.test.ts
Normal file
383
src/components/builder/useBuilderSave.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
|
||||
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
|
||||
const mockSaveWorkflowAs = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<boolean | null>>()
|
||||
)
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockSetWorkflowDefaultView = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockActiveWorkflow = ref<{
|
||||
filename: string
|
||||
initialMode?: string | null
|
||||
} | null>(null)
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ toastErrorHandler: mockToastErrorHandler })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEnterLinear: mockTrackEnterLinear })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
saveWorkflow: mockSaveWorkflow,
|
||||
saveWorkflowAs: mockSaveWorkflowAs
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: mockCloseDialog })
|
||||
}))
|
||||
|
||||
vi.mock('./builderViewOptions', () => ({
|
||||
setWorkflowDefaultView: mockSetWorkflowDefaultView
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
|
||||
showConfirmDialog: mockShowConfirmDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) return `${key}:${JSON.stringify(params)}`
|
||||
return key
|
||||
}
|
||||
}))
|
||||
|
||||
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'
|
||||
|
||||
async function importComposable() {
|
||||
const { useBuilderSave } = await import('./useBuilderSave')
|
||||
return useBuilderSave()
|
||||
}
|
||||
|
||||
describe('useBuilderSave', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = null
|
||||
})
|
||||
|
||||
describe('save()', () => {
|
||||
it('does nothing when there is no active workflow', async () => {
|
||||
mockActiveWorkflow.value = null
|
||||
const { save } = await importComposable()
|
||||
|
||||
save()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens confirm dialog with correct key and callbacks', 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()
|
||||
|
||||
await onSave()
|
||||
|
||||
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 () => {
|
||||
const error = new Error('save failed')
|
||||
mockSaveWorkflow.mockRejectedValueOnce(error)
|
||||
const { onSave } = await getConfirmDialogProps()
|
||||
|
||||
await onSave()
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveAs()', () => {
|
||||
it('does nothing when there is no active workflow', async () => {
|
||||
mockActiveWorkflow.value = null
|
||||
const { saveAs } = await importComposable()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens save dialog with correct defaultFilename and defaultOpenAsApp', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = await importComposable()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
|
||||
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(key).toBe(SAVE_DIALOG_KEY)
|
||||
expect(props.defaultFilename).toBe('my-workflow')
|
||||
expect(props.defaultOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes defaultOpenAsApp: false when initialMode is graph', async () => {
|
||||
mockActiveWorkflow.value = {
|
||||
filename: 'my-workflow',
|
||||
initialMode: 'graph'
|
||||
}
|
||||
const { saveAs } = await importComposable()
|
||||
|
||||
saveAs()
|
||||
|
||||
const { props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(props.defaultOpenAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save dialog callbacks', () => {
|
||||
async function getSaveDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = await importComposable()
|
||||
saveAs()
|
||||
return mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onSave calls saveWorkflowAs then setWorkflowDefaultView on success', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = await getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name'
|
||||
}
|
||||
)
|
||||
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('onSave does not mutate or close when saveWorkflowAs returns falsy', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(null)
|
||||
const { onSave } = await getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockSetWorkflowDefaultView).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onSave closes dialog and shows success dialog after successful save', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = await getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
|
||||
})
|
||||
|
||||
it('shows app success message when openAsApp is true', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = await getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
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 () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = await getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyGraph')
|
||||
expect(successCall.footerProps.confirmText).toBe(
|
||||
'linearMode.builder.exit'
|
||||
)
|
||||
expect(successCall.footerProps.cancelText).toBe('builderToolbar.viewApp')
|
||||
})
|
||||
|
||||
it('onSave toasts error and closes dialog on failure', async () => {
|
||||
const error = new Error('save-as failed')
|
||||
mockSaveWorkflowAs.mockRejectedValueOnce(error)
|
||||
const { onSave } = await getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
})
|
||||
})
|
||||
|
||||
describe('showSuccessDialog callbacks', () => {
|
||||
async function getSuccessDialogProps() {
|
||||
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()
|
||||
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onConfirm closes dialog, tracks telemetry, and sets mode to app', async () => {
|
||||
const { onConfirm } = await getSuccessDialogProps()
|
||||
|
||||
onConfirm()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'app_builder'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onCancel closes success dialog', async () => {
|
||||
const { onCancel } = await getSuccessDialogProps()
|
||||
|
||||
onCancel()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph success dialog callbacks', () => {
|
||||
async function getGraphSuccessDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { saveAs } = await importComposable()
|
||||
saveAs()
|
||||
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
}
|
||||
await onSave('new-name', false)
|
||||
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onConfirm closes dialog and exits builder', async () => {
|
||||
const { onConfirm } = await getGraphSuccessDialogProps()
|
||||
|
||||
onConfirm()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onCancel closes dialog and switches to app mode', async () => {
|
||||
const { onCancel } = await getGraphSuccessDialogProps()
|
||||
|
||||
onCancel()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'app_builder'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
})
|
||||
})
|
||||
146
src/components/builder/useBuilderSave.ts
Normal file
146
src/components/builder/useBuilderSave.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
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() {
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { setMode } = useAppMode()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function closeDialog(key: string) {
|
||||
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() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
try {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog()
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
} finally {
|
||||
closeDialog(CONFIRM_DIALOG_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function saveAs() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: SAVE_DIALOG_KEY,
|
||||
component: BuilderSaveDialogContent,
|
||||
props: {
|
||||
defaultFilename: workflow.filename,
|
||||
defaultOpenAsApp: workflow.initialMode !== 'graph',
|
||||
onSave: handleSaveAs,
|
||||
onClose: () => closeDialog(SAVE_DIALOG_KEY)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveAs(filename: string, openAsApp: boolean) {
|
||||
try {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
setWorkflowDefaultView(workflow, openAsApp)
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
showSuccessDialog(openAsApp ? 'app' : 'graph')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(viewType?: 'app' | 'graph') {
|
||||
const promptText =
|
||||
viewType === 'app'
|
||||
? t('builderSave.successBodyApp')
|
||||
: viewType === 'graph'
|
||||
? t('builderSave.successBodyGraph')
|
||||
: t('builderSave.successBody')
|
||||
|
||||
showConfirmDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
headerProps: {
|
||||
title: t('builderSave.successTitle'),
|
||||
icon: 'icon-[lucide--circle-check-big] text-green-500'
|
||||
},
|
||||
props: { promptText, preserveNewlines: true },
|
||||
footerProps:
|
||||
viewType === 'graph'
|
||||
? {
|
||||
cancelText: t('builderToolbar.viewApp'),
|
||||
confirmText: t('linearMode.builder.exit'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
}
|
||||
: {
|
||||
cancelText: t('g.close'),
|
||||
confirmText: t('builderToolbar.viewApp'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { save, saveAs }
|
||||
}
|
||||
@@ -4,13 +4,10 @@ import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
const BUILDER_STEPS = [
|
||||
'builder:inputs',
|
||||
'builder:outputs',
|
||||
'builder:arrange',
|
||||
'setDefaultView'
|
||||
'builder:arrange'
|
||||
] as const
|
||||
|
||||
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
|
||||
@@ -19,10 +16,8 @@ const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
|
||||
|
||||
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
const { mode, isBuilderMode, setMode } = useAppMode()
|
||||
const { settingView, showDialog } = useAppSetDefaultView()
|
||||
|
||||
const activeStep = computed<BuilderStepId>(() => {
|
||||
if (settingView.value) return 'setDefaultView'
|
||||
if (isBuilderMode.value) {
|
||||
return mode.value as BuilderStepId
|
||||
}
|
||||
@@ -47,23 +42,14 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
activeStep.value === 'builder:outputs'
|
||||
)
|
||||
|
||||
function navigateToStep(stepId: BuilderStepId) {
|
||||
if (stepId === 'setDefaultView') {
|
||||
setMode('builder:arrange')
|
||||
showDialog()
|
||||
} else {
|
||||
setMode(stepId)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (isFirstStep.value) return
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (isLastStep.value) return
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -72,7 +58,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
navigateToStep: setMode,
|
||||
goBack,
|
||||
goNext
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-inter text-sm font-bold text-base-foreground"
|
||||
>
|
||||
<i v-if="icon" :class="cn(icon, 'size-4')" aria-hidden="true" />
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
icon?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
key?: string
|
||||
headerProps?: ComponentAttrs<typeof ConfirmHeader>
|
||||
props?: ComponentAttrs<typeof ConfirmBody>
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
@@ -12,8 +13,9 @@ interface ConfirmDialogOptions {
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { headerProps, props, footerProps } = options
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
key,
|
||||
headerComponent: ConfirmHeader,
|
||||
component: ConfirmBody,
|
||||
footerComponent: ConfirmFooter,
|
||||
|
||||
@@ -3670,28 +3670,34 @@
|
||||
"outputsDescription": "Choose outputs",
|
||||
"arrange": "Preview",
|
||||
"arrangeDescription": "Review app layout",
|
||||
"defaultView": "Set a default view",
|
||||
"defaultViewDescription": "Choose how this opens",
|
||||
"connectOutput": "Connect an output",
|
||||
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
|
||||
"connectOutputBody2": "Switch to the 'Output' step and click on output nodes to add them here.",
|
||||
"switchToOutputs": "Switch to Outputs",
|
||||
"defaultViewTitle": "Set the default view for this workflow",
|
||||
"defaultViewLabel": "By default, this workflow will open as:",
|
||||
"app": "App",
|
||||
"appDescription": "Opens as an app by default",
|
||||
"nodeGraph": "Node graph",
|
||||
"nodeGraphDescription": "Opens as node graph by default",
|
||||
"defaultModeAppliedTitle": "Successfully set",
|
||||
"defaultModeAppliedAppBody": "This workflow will open in App Mode by default from now on.",
|
||||
"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",
|
||||
"exitToWorkflow": "Exit to workflow",
|
||||
"saveAs": "Save as",
|
||||
"filename": "Filename",
|
||||
"emptyWorkflowTitle": "This workflow has no nodes",
|
||||
"emptyWorkflowPrompt": "Do you want to start with a template?"
|
||||
},
|
||||
"builderFooter": {
|
||||
"opensAsApp": "Open as an {mode}",
|
||||
"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?",
|
||||
"successBodyGraph": "This workflow will open as a node graph."
|
||||
},
|
||||
"builderMenu": {
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { vi } from 'vitest'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
/**
|
||||
@@ -264,6 +265,18 @@ export function createMockChangeTracker(
|
||||
return partial as Partial<ChangeTracker> as ChangeTracker
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock LoadedComfyWorkflow with sensible defaults
|
||||
*/
|
||||
export function createMockLoadedWorkflow(
|
||||
overrides: Partial<LoadedComfyWorkflow> | Record<string, unknown> = {}
|
||||
): LoadedComfyWorkflow {
|
||||
return {
|
||||
changeTracker: createMockChangeTracker(),
|
||||
...overrides
|
||||
} as unknown as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock MinimapCanvas for minimap testing
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user