mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
feat: Replace BuilderExitButton with new BuilderFooterToolbar (#9378)
## Summary Makes it easier and more obvious for users to navigate between steps ## Changes - **What**: - add back/next navigation to builder footer alongside exit button - extract shared step logic into useBuilderSteps composable ## Screenshots (if applicable) <img width="428" height="102" alt="image" src="https://github.com/user-attachments/assets/91b33e8f-53ae-4895-a2eb-fb1316b2b367" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9378-feat-Replace-BuilderExitButton-with-new-BuilderFooterToolbar-3196d73d3650819392efc171cf277326) by [Unito](https://www.unito.io)
This commit is contained in:
147
src/components/builder/BuilderFooterToolbar.test.ts
Normal file
147
src/components/builder/BuilderFooterToolbar.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { AppMode } from '@/composables/useAppMode'
|
||||||
|
|
||||||
|
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 mockState = {
|
||||||
|
mode: 'builder:select' as AppMode,
|
||||||
|
settingView: false
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/composables/useAppMode', () => ({
|
||||||
|
useAppMode: () => ({
|
||||||
|
mode: computed(() => mockState.mode),
|
||||||
|
isBuilderMode: ref(true),
|
||||||
|
setMode: mockSetMode
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockHasOutputs = ref(true)
|
||||||
|
|
||||||
|
vi.mock('@/stores/appModeStore', () => ({
|
||||||
|
useAppModeStore: () => ({
|
||||||
|
exitBuilder: mockExitBuilder,
|
||||||
|
hasOutputs: mockHasOutputs,
|
||||||
|
$id: 'appMode'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/dialogStore', () => ({
|
||||||
|
useDialogStore: () => ({
|
||||||
|
dialogStack: []
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
|
||||||
|
useAppSetDefaultView: () => ({
|
||||||
|
settingView: computed(() => mockState.settingView),
|
||||||
|
showDialog: mockShowDialog
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
builderMenu: { exitAppBuilder: 'Exit app builder' },
|
||||||
|
g: { back: 'Back', next: 'Next' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BuilderFooterToolbar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockState.mode = 'builder:select'
|
||||||
|
mockHasOutputs.value = true
|
||||||
|
mockState.settingView = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountComponent() {
|
||||||
|
return mount(BuilderFooterToolbar, {
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
stubs: { Button: false }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
return {
|
||||||
|
exit: buttons[0],
|
||||||
|
back: buttons[1],
|
||||||
|
next: buttons[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('disables back on the first step', () => {
|
||||||
|
mockState.mode = 'builder:select'
|
||||||
|
const { back } = getButtons(mountComponent())
|
||||||
|
expect(back.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables back on the second step', () => {
|
||||||
|
mockState.mode = 'builder:arrange'
|
||||||
|
const { back } = getButtons(mountComponent())
|
||||||
|
expect(back.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables next on the setDefaultView step', () => {
|
||||||
|
mockState.settingView = true
|
||||||
|
const { next } = getButtons(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())
|
||||||
|
expect(next.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables next on select step', () => {
|
||||||
|
mockState.mode = 'builder:select'
|
||||||
|
const { next } = getButtons(mountComponent())
|
||||||
|
expect(next.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls setMode on back click', async () => {
|
||||||
|
mockState.mode = 'builder:arrange'
|
||||||
|
const { back } = getButtons(mountComponent())
|
||||||
|
await back.trigger('click')
|
||||||
|
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls setMode on next click from select step', async () => {
|
||||||
|
mockState.mode = 'builder:select'
|
||||||
|
const { next } = getButtons(mountComponent())
|
||||||
|
await next.trigger('click')
|
||||||
|
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
|
||||||
|
})
|
||||||
|
|
||||||
|
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())
|
||||||
|
await exit.trigger('click')
|
||||||
|
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<nav
|
||||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
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"
|
||||||
>
|
>
|
||||||
<Button size="lg" @click="onExitBuilder">
|
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||||
{{ t('builderMenu.exitAppBuilder') }}
|
{{ t('builderMenu.exitAppBuilder') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<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>
|
||||||
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventListener } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
@@ -17,10 +31,16 @@ import { useAppMode } from '@/composables/useAppMode'
|
|||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
import { useBuilderSteps } from './useBuilderSteps'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appModeStore = useAppModeStore()
|
const appModeStore = useAppModeStore()
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const { isBuilderMode } = useAppMode()
|
const { isBuilderMode } = useAppMode()
|
||||||
|
const { hasOutputs } = storeToRefs(appModeStore)
|
||||||
|
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
|
||||||
|
hasOutputs
|
||||||
|
})
|
||||||
|
|
||||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||||
@click="setMode(step.id)"
|
@click="navigateToStep(step.id)"
|
||||||
>
|
>
|
||||||
<StepBadge :step :index :model-value="activeStep" />
|
<StepBadge :step :index :model-value="activeStep" />
|
||||||
<StepLabel :step />
|
<StepLabel :step />
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<ConnectOutputPopover
|
<ConnectOutputPopover
|
||||||
v-if="!hasOutputs"
|
v-if="!hasOutputs"
|
||||||
:is-select-active="activeStep === 'builder:select'"
|
:is-select-active="activeStep === 'builder:select'"
|
||||||
@switch="setMode('builder:select')"
|
@switch="navigateToStep('builder:select')"
|
||||||
>
|
>
|
||||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||||
<StepBadge
|
<StepBadge
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
: 'hover:bg-secondary-background bg-transparent'
|
: 'hover:bg-secondary-background bg-transparent'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="showDialog()"
|
@click="navigateToStep('setDefaultView')"
|
||||||
>
|
>
|
||||||
<StepBadge
|
<StepBadge
|
||||||
:step="defaultViewStep"
|
:step="defaultViewStep"
|
||||||
@@ -69,11 +69,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useAppMode } from '@/composables/useAppMode'
|
|
||||||
import type { AppMode } from '@/composables/useAppMode'
|
|
||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
@@ -81,35 +78,32 @@ import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
|||||||
import StepBadge from './StepBadge.vue'
|
import StepBadge from './StepBadge.vue'
|
||||||
import StepLabel from './StepLabel.vue'
|
import StepLabel from './StepLabel.vue'
|
||||||
import type { BuilderToolbarStep } from './types'
|
import type { BuilderToolbarStep } from './types'
|
||||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
import type { BuilderStepId } from './useBuilderSteps'
|
||||||
|
import { useBuilderSteps } from './useBuilderSteps'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { mode, setMode } = useAppMode()
|
const appModeStore = useAppModeStore()
|
||||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
const { hasOutputs } = storeToRefs(appModeStore)
|
||||||
const { settingView, showDialog } = useAppSetDefaultView()
|
const { activeStep, navigateToStep } = useBuilderSteps()
|
||||||
|
|
||||||
const activeStep = computed(() =>
|
|
||||||
settingView.value ? 'setDefaultView' : mode.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const stepClasses =
|
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'
|
'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'
|
||||||
|
|
||||||
const selectStep: BuilderToolbarStep<AppMode> = {
|
const selectStep: BuilderToolbarStep<BuilderStepId> = {
|
||||||
id: 'builder:select',
|
id: 'builder:select',
|
||||||
title: t('builderToolbar.select'),
|
title: t('builderToolbar.select'),
|
||||||
subtitle: t('builderToolbar.selectDescription'),
|
subtitle: t('builderToolbar.selectDescription'),
|
||||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrangeStep: BuilderToolbarStep<AppMode> = {
|
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||||
id: 'builder:arrange',
|
id: 'builder:arrange',
|
||||||
title: t('builderToolbar.arrange'),
|
title: t('builderToolbar.arrange'),
|
||||||
subtitle: t('builderToolbar.arrangeDescription'),
|
subtitle: t('builderToolbar.arrangeDescription'),
|
||||||
icon: 'icon-[lucide--layout-panel-left]'
|
icon: 'icon-[lucide--layout-panel-left]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultViewStep: BuilderToolbarStep<'setDefaultView'> = {
|
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||||
id: 'setDefaultView',
|
id: 'setDefaultView',
|
||||||
title: t('builderToolbar.defaultView'),
|
title: t('builderToolbar.defaultView'),
|
||||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||||
|
|||||||
71
src/components/builder/useBuilderSteps.ts
Normal file
71
src/components/builder/useBuilderSteps.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useAppMode } from '@/composables/useAppMode'
|
||||||
|
|
||||||
|
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||||
|
|
||||||
|
const BUILDER_STEPS = [
|
||||||
|
'builder:select',
|
||||||
|
'builder:arrange',
|
||||||
|
'setDefaultView'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return 'builder:select'
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeStepIndex = computed(() =>
|
||||||
|
BUILDER_STEPS.indexOf(activeStep.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const isFirstStep = computed(() => activeStepIndex.value === 0)
|
||||||
|
|
||||||
|
const isLastStep = computed(() => {
|
||||||
|
if (!options?.hasOutputs?.value)
|
||||||
|
return activeStepIndex.value >= ARRANGE_INDEX
|
||||||
|
return activeStepIndex.value >= BUILDER_STEPS.length - 1
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (isLastStep.value) return
|
||||||
|
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeStep,
|
||||||
|
activeStepIndex,
|
||||||
|
isFirstStep,
|
||||||
|
isLastStep,
|
||||||
|
navigateToStep,
|
||||||
|
goBack,
|
||||||
|
goNext
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<template v-if="isBuilderMode">
|
<template v-if="isBuilderMode">
|
||||||
<BuilderToolbar />
|
<BuilderToolbar />
|
||||||
<BuilderMenu />
|
<BuilderMenu />
|
||||||
<BuilderExitButton />
|
<BuilderFooterToolbar />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
|||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
import { electronAPI } from '@/utils/envUtil'
|
import { electronAPI } from '@/utils/envUtil'
|
||||||
import BuilderExitButton from '@/components/builder/BuilderExitButton.vue'
|
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||||
import BuilderMenu from '@/components/builder/BuilderMenu.vue'
|
import BuilderMenu from '@/components/builder/BuilderMenu.vue'
|
||||||
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
||||||
import LinearView from '@/views/LinearView.vue'
|
import LinearView from '@/views/LinearView.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user