mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 13:10:24 +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>
|
||||
<div
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
<Button size="lg" @click="onExitBuilder">
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -17,10 +31,16 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
|
||||
hasOutputs
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -20,7 +20,7 @@
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="setMode(step.id)"
|
||||
@click="navigateToStep(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
@@ -33,7 +33,7 @@
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="setMode('builder:select')"
|
||||
@switch="navigateToStep('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge
|
||||
@@ -54,7 +54,7 @@
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="showDialog()"
|
||||
@click="navigateToStep('setDefaultView')"
|
||||
>
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
@@ -69,11 +69,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -81,35 +78,32 @@ import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
import type { BuilderStepId } from './useBuilderSteps'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { mode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { settingView, showDialog } = useAppSetDefaultView()
|
||||
|
||||
const activeStep = computed(() =>
|
||||
settingView.value ? 'setDefaultView' : mode.value
|
||||
)
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
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'
|
||||
|
||||
const selectStep: BuilderToolbarStep<AppMode> = {
|
||||
const selectStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'builder:select',
|
||||
title: t('builderToolbar.select'),
|
||||
subtitle: t('builderToolbar.selectDescription'),
|
||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||
}
|
||||
|
||||
const arrangeStep: BuilderToolbarStep<AppMode> = {
|
||||
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'builder:arrange',
|
||||
title: t('builderToolbar.arrange'),
|
||||
subtitle: t('builderToolbar.arrangeDescription'),
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const defaultViewStep: BuilderToolbarStep<'setDefaultView'> = {
|
||||
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'setDefaultView',
|
||||
title: t('builderToolbar.defaultView'),
|
||||
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">
|
||||
<BuilderToolbar />
|
||||
<BuilderMenu />
|
||||
<BuilderExitButton />
|
||||
<BuilderFooterToolbar />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
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 BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
|
||||
Reference in New Issue
Block a user