Split selection into an inputs and outputs step (#9362)

When building an app, selecting inputs and selecting outputs are now 2
separate steps. This prevents confusion where clicking on the widget of
an output node will select that widget instead of the entire output.

<img width="1673" height="773" alt="image"
src="https://github.com/user-attachments/assets/e5994479-6fcf-4572-b58b-bf8cecfb7d55"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9362-Split-selection-into-an-inputs-and-outputs-step-3196d73d36508187b4a1e51c73f1c54c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
AustinMroz
2026-03-04 15:18:16 -08:00
committed by GitHub
parent 316a05c77f
commit 57a919fad2
15 changed files with 147 additions and 115 deletions

View File

@@ -39,7 +39,8 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isArrangeMode } = useAppMode()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
@@ -161,6 +162,7 @@ function handleClick(e: MouseEvent) {
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
@@ -168,6 +170,7 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
@@ -234,7 +237,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</div>
</DraggableList>
<PropertiesAccordionItem
v-else
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
@@ -283,7 +286,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="!isArrangeMode"
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
@@ -344,42 +347,46 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
<template v-if="isSelectInputsMode">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>
</template>
</TransformPane>
</div>
</Teleport>

View File

@@ -63,7 +63,7 @@ describe('BuilderFooterToolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockState.mode = 'builder:select'
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockState.settingView = false
})
@@ -87,7 +87,7 @@ describe('BuilderFooterToolbar', () => {
}
it('disables back on the first step', () => {
mockState.mode = 'builder:select'
mockState.mode = 'builder:inputs'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
})
@@ -111,8 +111,8 @@ describe('BuilderFooterToolbar', () => {
expect(next.attributes('disabled')).toBeDefined()
})
it('enables next on select step', () => {
mockState.mode = 'builder:select'
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
})
@@ -121,14 +121,14 @@ describe('BuilderFooterToolbar', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('calls setMode on next click from select step', async () => {
mockState.mode = 'builder:select'
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {

View File

@@ -6,17 +6,14 @@
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<template v-for="(step, index) in steps" :key="step.id">
<button
:class="
cn(
stepClasses,
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@@ -32,13 +29,13 @@
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="navigateToStep('builder:select')"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="2"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
@@ -58,7 +55,7 @@
>
<StepBadge
:step="defaultViewStep"
:index="2"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
@@ -84,15 +81,22 @@ import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, navigateToStep } = useBuilderSteps()
const { activeStep, isSelectStep, 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<BuilderStepId> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
@@ -109,4 +113,5 @@ const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -46,7 +46,7 @@
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToSelect') }}
{{ t('builderToolbar.switchToOutputs') }}
</Button>
</PopoverClose>
</template>

View File

@@ -7,7 +7,8 @@ import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:select',
'builder:inputs',
'builder:outputs',
'builder:arrange',
'setDefaultView'
] as const
@@ -25,7 +26,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
return 'builder:select'
return 'builder:inputs'
})
const activeStepIndex = computed(() =>
@@ -40,6 +41,12 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
const isSelectStep = computed(
() =>
activeStep.value === 'builder:inputs' ||
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
@@ -64,6 +71,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
activeStepIndex,
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext

View File

@@ -39,8 +39,8 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
<AppBuilder v-if="isBuilderMode" />
<NodePropertiesPanel v-else />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -204,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const { mode, isBuilderMode } = useAppMode()
const { isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()