mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 05:00:03 +00:00
feat: App mode enter builder menu item (#9341)
## Summary Adds enter builder menu item for easier access to app builder. Fixes issues with seen item tracking ## Changes - **What**: - add enter builder menu item - change non visible items to still be returned as part of the array, so they are not incorrectly removed from the seen-items tracking - split toggle-app-mode into two stable items ## Screenshots (if applicable) <img width="309" height="526" alt="image" src="https://github.com/user-attachments/assets/69affc2c-34ab-45eb-b47b-efacb8a20b99" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9341-feat-App-mode-enter-builder-menu-item-3176d73d365081a9a7e7cf1a1986354f) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -70,6 +70,24 @@ describe('WorkflowActionsList', () => {
|
||||
expect(wrapper.text()).toContain('NEW')
|
||||
})
|
||||
|
||||
it('does not render items with visible set to false', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
id: 'hidden',
|
||||
label: 'Hidden Item',
|
||||
icon: 'pi pi-eye-slash',
|
||||
command: vi.fn(),
|
||||
visible: false
|
||||
},
|
||||
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('Hidden Item')
|
||||
expect(wrapper.text()).toContain('Shown Item')
|
||||
})
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
/>
|
||||
<component
|
||||
:is="itemComponent"
|
||||
v-else
|
||||
v-else-if="item.visible !== false"
|
||||
:disabled="item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -98,6 +98,80 @@ describe('useNewMenuItemIndicator', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not count hidden items as unseen', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
id: 'hidden-feature',
|
||||
label: 'Hidden',
|
||||
icon: 'pi pi-test',
|
||||
command: vi.fn(),
|
||||
isNew: true,
|
||||
badge: 'BETA',
|
||||
visible: false
|
||||
}
|
||||
]
|
||||
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
expect(hasUnseenItems.value).toBe(false)
|
||||
})
|
||||
|
||||
it('markAsSeen does not include never-seen hidden items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
...createItems('feature-a'),
|
||||
{
|
||||
id: 'hidden-feature',
|
||||
label: 'Hidden',
|
||||
icon: 'pi pi-test',
|
||||
command: vi.fn(),
|
||||
isNew: true,
|
||||
badge: 'BETA',
|
||||
visible: false
|
||||
}
|
||||
]
|
||||
const { markAsSeen } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
markAsSeen()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.WorkflowActions.SeenItems',
|
||||
['feature-a']
|
||||
)
|
||||
})
|
||||
|
||||
it('markAsSeen retains previously-seen hidden items', () => {
|
||||
mockSettingStore.get.mockReturnValue(['hidden-feature'])
|
||||
const items: WorkflowMenuItem[] = [
|
||||
...createItems('feature-a'),
|
||||
{
|
||||
id: 'hidden-feature',
|
||||
label: 'Hidden',
|
||||
icon: 'pi pi-test',
|
||||
command: vi.fn(),
|
||||
isNew: true,
|
||||
badge: 'BETA',
|
||||
visible: false
|
||||
}
|
||||
]
|
||||
const { markAsSeen } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
markAsSeen()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.WorkflowActions.SeenItems',
|
||||
['feature-a', 'hidden-feature']
|
||||
)
|
||||
})
|
||||
|
||||
it('markAsSeen skips write when stored list already matches', () => {
|
||||
mockSettingStore.get.mockReturnValue(['feature-a', 'feature-b'])
|
||||
const items = createItems('feature-a', 'feature-b')
|
||||
const { markAsSeen } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
markAsSeen()
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('markAsSeen does nothing when there are no new items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }
|
||||
|
||||
@@ -7,11 +7,10 @@ import type {
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
function getNewItemIds(items: WorkflowMenuItem[]): string[] {
|
||||
function getNewActions(items: WorkflowMenuItem[]): WorkflowMenuAction[] {
|
||||
return items
|
||||
.filter((i): i is WorkflowMenuAction => !('separator' in i && i.separator))
|
||||
.filter((i) => i.isNew)
|
||||
.map((i) => i.id)
|
||||
}
|
||||
|
||||
export function useNewMenuItemIndicator(
|
||||
@@ -19,7 +18,7 @@ export function useNewMenuItemIndicator(
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const newItemIds = computed(() => getNewItemIds(toValue(menuItems)))
|
||||
const newActions = computed(() => getNewActions(toValue(menuItems)))
|
||||
|
||||
const seenItems = computed<string[]>(
|
||||
() => settingStore.get('Comfy.WorkflowActions.SeenItems') ?? []
|
||||
@@ -27,14 +26,28 @@ export function useNewMenuItemIndicator(
|
||||
|
||||
const hasUnseenItems = computed(() => {
|
||||
const seen = new Set(seenItems.value)
|
||||
return newItemIds.value.some((id) => !seen.has(id))
|
||||
return newActions.value
|
||||
.filter((i) => i.visible !== false)
|
||||
.some((i) => !seen.has(i.id))
|
||||
})
|
||||
|
||||
function markAsSeen() {
|
||||
if (!newItemIds.value.length) return
|
||||
void settingStore.set('Comfy.WorkflowActions.SeenItems', [
|
||||
...newItemIds.value
|
||||
])
|
||||
const actions = newActions.value
|
||||
if (!actions.length) return
|
||||
|
||||
const seen = new Set(seenItems.value)
|
||||
const visibleIds = actions
|
||||
.filter((i) => i.visible !== false)
|
||||
.map((i) => i.id)
|
||||
const retainedIds = actions
|
||||
.filter((i) => i.visible === false && seen.has(i.id))
|
||||
.map((i) => i.id)
|
||||
|
||||
const nextSeen = [...visibleIds, ...retainedIds]
|
||||
if (nextSeen.length === seen.size && nextSeen.every((id) => seen.has(id)))
|
||||
return
|
||||
|
||||
void settingStore.set('Comfy.WorkflowActions.SeenItems', nextSeen)
|
||||
}
|
||||
|
||||
return { hasUnseenItems, markAsSeen }
|
||||
|
||||
@@ -44,6 +44,10 @@ const mockCanvasStore = vi.hoisted(() => ({
|
||||
linearMode: false
|
||||
}))
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
enterBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
flags: { linearToggleEnabled: false }
|
||||
}))
|
||||
@@ -73,6 +77,10 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: vi.fn(() => mockAppModeStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => mockFeatureFlags)
|
||||
}))
|
||||
@@ -80,7 +88,9 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
type MenuItems = ReturnType<typeof useWorkflowActionsMenu>['menuItems']['value']
|
||||
|
||||
function actionItems(items: MenuItems): WorkflowMenuAction[] {
|
||||
return items.filter((i): i is WorkflowMenuAction => !i.separator)
|
||||
return items.filter(
|
||||
(i): i is WorkflowMenuAction => !i.separator && i.visible !== false
|
||||
)
|
||||
}
|
||||
|
||||
function menuLabels(items: MenuItems) {
|
||||
@@ -288,6 +298,18 @@ describe('useWorkflowActionsMenu', () => {
|
||||
expect(mockBookmarkStore.toggleBookmarked).toHaveBeenCalledWith('test.json')
|
||||
})
|
||||
|
||||
it('enter builder mode calls enterBuilder', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(
|
||||
menuItems.value,
|
||||
'breadcrumbsMenu.enterBuilderMode'
|
||||
).command?.()
|
||||
|
||||
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('app mode toggle executes Comfy.ToggleLinear', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type {
|
||||
WorkflowMenuAction,
|
||||
WorkflowMenuItem
|
||||
@@ -52,6 +53,7 @@ export function useWorkflowActionsMenu(
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { enterBuilder } = useAppModeStore()
|
||||
|
||||
const targetWorkflow = computed(
|
||||
() => workflow?.value ?? workflowStore.activeWorkflow
|
||||
@@ -81,9 +83,9 @@ export function useWorkflowActionsMenu(
|
||||
prependSeparator = false,
|
||||
isNew = false
|
||||
}: AddItemOptions) => {
|
||||
if (!visible) return
|
||||
if (prependSeparator) items.push({ separator: true })
|
||||
if (prependSeparator && visible) items.push({ separator: true })
|
||||
const item: WorkflowMenuAction = { id, label, icon, command, disabled }
|
||||
if (!visible) item.visible = false
|
||||
if (isNew) {
|
||||
item.badge = t('g.experimental')
|
||||
item.isNew = true
|
||||
@@ -96,6 +98,11 @@ export function useWorkflowActionsMenu(
|
||||
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
|
||||
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
|
||||
|
||||
const toggleLinear = async () => {
|
||||
await commandStore.execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'breadcrumb_menu' }
|
||||
})
|
||||
}
|
||||
addItem({
|
||||
id: 'rename',
|
||||
label: t('g.rename'),
|
||||
@@ -181,21 +188,31 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'toggle-app-mode',
|
||||
label: isLinearMode
|
||||
? t('breadcrumbsMenu.exitAppMode')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
icon: isLinearMode
|
||||
? 'icon-[comfy--workflow]'
|
||||
: 'icon-[lucide--panels-top-left]',
|
||||
command: async () => {
|
||||
await commandStore.execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'breadcrumb_menu' }
|
||||
})
|
||||
},
|
||||
visible: showAppModeItems,
|
||||
id: 'enter-app-mode',
|
||||
label: t('breadcrumbsMenu.enterAppMode'),
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
command: toggleLinear,
|
||||
visible: showAppModeItems && !isLinearMode,
|
||||
prependSeparator: true,
|
||||
isNew: !isLinearMode
|
||||
isNew: true
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'exit-app-mode',
|
||||
label: t('breadcrumbsMenu.exitAppMode'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
command: toggleLinear,
|
||||
visible: isLinearMode,
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'enter-builder-mode',
|
||||
label: t('breadcrumbsMenu.enterBuilderMode'),
|
||||
icon: 'icon-[lucide--hammer]',
|
||||
command: () => enterBuilder(),
|
||||
visible: showAppModeItems,
|
||||
isNew: true
|
||||
})
|
||||
|
||||
addItem({
|
||||
|
||||
@@ -2589,6 +2589,7 @@
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
"enterBuilderMode": "Enter app builder",
|
||||
"workflowActions": "Workflow actions",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
|
||||
@@ -8,6 +8,7 @@ interface WorkflowMenuSeparator {
|
||||
|
||||
export interface WorkflowMenuAction {
|
||||
separator?: false
|
||||
visible?: boolean
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
|
||||
Reference in New Issue
Block a user