mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
22 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529e38bdb7 | ||
|
|
fbcd36d355 | ||
|
|
e594164b71 | ||
|
|
245f840e7c | ||
|
|
240b54419b | ||
|
|
d9020b7fbe | ||
|
|
c4272ef1da | ||
|
|
2ef354447d | ||
|
|
55789ef0fb | ||
|
|
7add2c03e9 | ||
|
|
c81bc8400c | ||
|
|
af5a72021b | ||
|
|
4e5bb3e540 | ||
|
|
2ccfb822b4 | ||
|
|
370003da94 | ||
|
|
3b5af4960f | ||
|
|
46895ee1a9 | ||
|
|
7f0472fde4 | ||
|
|
24ac6388d7 | ||
|
|
6b6049e48e | ||
|
|
592f992d1d | ||
|
|
76fd80aa98 |
13
.github/workflows/cloud-dispatch-build.yaml
vendored
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- 'cloud/*'
|
||||
- 'main'
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
types: [labeled, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
@@ -26,11 +26,18 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
# Fork guard: prevent forks from dispatching to the cloud repo.
|
||||
# For pull_request events, only dispatch when the 'preview' label is added.
|
||||
# For pull_request events, only dispatch for preview labels.
|
||||
# - labeled: fires when a label is added; check the added label name.
|
||||
# - synchronize: fires on push; check existing labels on the PR.
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.label.name == 'preview')
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -40,6 +40,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
|
||||
// Selection toolbox should be visible with multiple nodes selected
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
// Wait for the canvas render queue to settle before taking the screenshot
|
||||
await comfyPage.nextFrame()
|
||||
// Border is now drawn on canvas, check via screenshot
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-multiple-nodes-border.png'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
62
docs/release-process.md
Normal file
62
docs/release-process.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Release Process
|
||||
|
||||
## Bump Types
|
||||
|
||||
All releases use `release-version-bump.yaml`. Effects differ by bump type:
|
||||
|
||||
| Bump | Target | Creates branches? | GitHub release |
|
||||
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
|
||||
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
|
||||
| Patch | `main` | No | Published, "latest" |
|
||||
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
|
||||
| Prerelease | any | No | Draft + prerelease |
|
||||
|
||||
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
|
||||
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
|
||||
bumps on `main` are convenience snapshots — no branches created.
|
||||
|
||||
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
|
||||
"latest" so `main` stays current.
|
||||
|
||||
### Dual-homed commits
|
||||
|
||||
When a minor bump happens, unreleased commits appear in both places:
|
||||
|
||||
```
|
||||
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
|
||||
│
|
||||
└── core/1.40
|
||||
```
|
||||
|
||||
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
|
||||
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
|
||||
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
|
||||
|
||||
## Backporting
|
||||
|
||||
1. Add `needs-backport` + version label to the merged PR
|
||||
2. `pr-backport.yaml` cherry-picks and creates a backport PR
|
||||
3. Conflicts produce a comment with details and an agent prompt
|
||||
|
||||
## Publishing
|
||||
|
||||
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
|
||||
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
|
||||
and npm (`@comfyorg/comfyui-frontend-types`).
|
||||
|
||||
## Bi-weekly ComfyUI Integration
|
||||
|
||||
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
|
||||
branch has unreleased commits, it triggers a patch bump and drafts a PR to
|
||||
`Comfy-Org/ComfyUI` updating `requirements.txt`.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------ |
|
||||
| `release-version-bump.yaml` | Bump version, create Release PR |
|
||||
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
|
||||
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
|
||||
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
|
||||
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
|
||||
| `cloud-backport-tag.yaml` | Tag cloud branch merges |
|
||||
@@ -34,17 +34,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
@@ -55,6 +45,7 @@
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:has-any-error="hasAnyError"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
@@ -70,7 +61,7 @@
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--share-2] size-4" />
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
@@ -123,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const actionBarButtonStore = useActionBarButtonStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
@@ -182,6 +175,43 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
/**
|
||||
* Whether the actionbar container has any visible docked buttons
|
||||
* (excluding ComfyActionbar, which uses position:fixed when floating
|
||||
* and does not contribute to the container's visual layout).
|
||||
*/
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
if (!isRightSidePanelOpen.value) return true
|
||||
return false
|
||||
})
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
const borderClass =
|
||||
!isActionbarFloating.value && hasAnyError.value
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
|
||||
return cn(base, 'px-2', borderClass)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
if (!el) {
|
||||
hasLegacyContent.value = false
|
||||
return
|
||||
}
|
||||
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
|
||||
hasLegacyContent.value =
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
|
||||
@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
const {
|
||||
topMenuContainer,
|
||||
queueOverlayExpanded = false,
|
||||
hasAnyError = false
|
||||
} = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
queueOverlayExpanded?: boolean
|
||||
hasAnyError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
|
||||
isDragging.value && 'pointer-events-none select-none',
|
||||
isDocked.value
|
||||
? 'static border-none bg-transparent p-0'
|
||||
: 'fixed shadow-interface'
|
||||
: [
|
||||
'fixed shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
]
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@ import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -27,7 +28,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -52,18 +53,15 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
return { nodeId, widgetName, node, widget }
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return node ? { nodeId, widgetName, node, widget } : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
@@ -108,7 +106,7 @@ function getHovered(
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
@@ -121,7 +119,6 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
@@ -160,12 +157,16 @@ function handleClick(e: MouseEvent) {
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
if (!isSelectInputsMode.value) return
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
|
||||
51
src/components/common/Dialogue.vue
Normal file
51
src/components/common/Dialogue.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ title?: string; to?: string | HTMLElement }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<DialogRoot v-slot="{ close }">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="button" />
|
||||
</DialogTrigger>
|
||||
<DialogPortal :to>
|
||||
<DialogOverlay
|
||||
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
|
||||
>
|
||||
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
:aria-label="t('g.close')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<slot :close />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: Omit<ComponentExposed<C>, 'focus'>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' },
|
||||
onSearch: { action: 'search' }
|
||||
},
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { SearchBox },
|
||||
setup() {
|
||||
const searchText = ref('')
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'md',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSize: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSizeWithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
size: 'lg',
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWidgets: {
|
||||
sort: {
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(SearchBox, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search functionality', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type search query
|
||||
await input.setValue('test')
|
||||
|
||||
// Model should not update immediately
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 299ms (just before debounce delay)
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 1ms more (reaching 300ms)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
// Model should now be updated
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Type first character
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type second character (should reset timer)
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
// Type third character (should reset timer again)
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet (only 200ms passed since last keystroke)
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance final 100ms to reach 300ms
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
// Should now emit with final value
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Simulate rapid typing
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Complete the debounce delay
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
// Should emit only once with final value
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
|
||||
})
|
||||
|
||||
describe('bidirectional model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = createWrapper({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
// Update model externally
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
// Internal state should sync
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = createWrapper({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
expect(input.attributes('aria-label')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use default placeholder when not provided', () => {
|
||||
const wrapper = createWrapper()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
expect(input.attributes('aria-label')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should focus input when autofocus is true', async () => {
|
||||
const wrapper = createWrapper({ autofocus: true })
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const inputElement = input.element as HTMLInputElement
|
||||
|
||||
// Note: In JSDOM, focus() doesn't actually set document.activeElement
|
||||
// We can only verify that the focus method exists and doesn't throw
|
||||
expect(inputElement.focus).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus when autofocus is false', () => {
|
||||
const wrapper = createWrapper({ autofocus: false })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(document.activeElement).not.toBe(input.element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click to focus', () => {
|
||||
it('should focus input when wrapper is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapperDiv = wrapper.find('[class*="flex"]')
|
||||
|
||||
await wrapperDiv.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Input should receive focus
|
||||
const input = wrapper.find('input').element as HTMLInputElement
|
||||
expect(input.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
|
||||
customClass,
|
||||
wrapperStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:placeholder
|
||||
:autofocus
|
||||
unstyled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
|
||||
isLarge ? 'pl-11' : 'pl-8'
|
||||
)
|
||||
"
|
||||
:aria-label="placeholder"
|
||||
/>
|
||||
<Button
|
||||
v-if="filterIcon"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
|
||||
@click="$emit('showFilter', $event)"
|
||||
>
|
||||
<i :class="filterIcon" />
|
||||
</Button>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@click="modelValue = ''"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="$emit('removeFilter', filter)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="TFilter extends SearchFilter">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import type { SearchFilter } from './SearchFilterChip.vue'
|
||||
import SearchFilterChip from './SearchFilterChip.vue'
|
||||
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = [],
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search', value: string, filters: TFilter[]): void
|
||||
(e: 'showFilter', event: Event): void
|
||||
(e: 'removeFilter', filter: TFilter): void
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value: string) => {
|
||||
emit('search', value, filters)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
'box-border rounded-sm border border-solid border-border-default p-2',
|
||||
isLarge.value ? 'h-10' : 'h-8'
|
||||
)
|
||||
}
|
||||
|
||||
// Size-specific classes matching button sizes for consistency
|
||||
const sizeClasses = {
|
||||
md: 'h-8 px-2 py-1.5', // Matches button sm size
|
||||
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||
}[size]
|
||||
|
||||
return cn('rounded-lg', sizeClasses)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBoxV2 from './SearchBoxV2.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
describe('SearchBoxV2', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchBoxV2, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('uses i18n placeholder when no placeholder prop provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
|
||||
it('uses custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
placeholder: 'Custom placeholder'
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('shows search icon when search term is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows clear button when search term is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears search term when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('applies large size classes when size is lg', () => {
|
||||
const wrapper = mountComponent({ size: 'lg' })
|
||||
expect(wrapper.html()).toContain('size-5')
|
||||
})
|
||||
|
||||
it('applies medium size classes when size is md', () => {
|
||||
const wrapper = mountComponent({ size: 'md' })
|
||||
expect(wrapper.html()).toContain('size-4')
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-auto flex-col gap-2">
|
||||
<ComboboxRoot :ignore-filter="true" :open="false">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-text items-center',
|
||||
'rounded-lg bg-comfy-input text-comfy-input-foreground',
|
||||
showBorder &&
|
||||
'box-border border border-solid border-border-default',
|
||||
sizeClasses,
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="!searchTerm"
|
||||
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="absolute left-2"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
|
||||
<ComboboxInput
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
:class="
|
||||
cn(
|
||||
'size-full border-none bg-transparent text-sm outline-none',
|
||||
inputPadding
|
||||
)
|
||||
"
|
||||
:placeholder="placeholderText"
|
||||
:auto-focus="autofocus"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
icon = 'icon-[lucide--search]',
|
||||
debounceTime = 300,
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [value: string]
|
||||
}>()
|
||||
|
||||
const searchTerm = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const isLarge = computed(() => size === 'lg')
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (showBorder) {
|
||||
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
|
||||
}
|
||||
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
|
||||
})
|
||||
|
||||
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
|
||||
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
|
||||
|
||||
function clearSearch() {
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchTerm,
|
||||
(value: string) => {
|
||||
emit('search', value)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
</script>
|
||||
@@ -155,6 +155,93 @@ describe('VirtualGrid', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits approach-end for single-column list when scrolled near bottom', async () => {
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 1,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
// Scroll near the end: 50 items * 48px = 2400px total
|
||||
// viewRows = ceil(600/48) = 13, buffer = 1
|
||||
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
|
||||
// toCol = (offsetRows + bufferRows + viewRows) * cols
|
||||
// offsetRows = floor(scrollY / 48)
|
||||
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
|
||||
// scrollY = 35 * 48 = 1680
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit approach-end without maxColumns in single-column layout', async () => {
|
||||
// Demonstrates the bug: without maxColumns=1, cols is calculated
|
||||
// from width/itemWidth (400/200 = 2), causing incorrect row math
|
||||
const items = createItems(50)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)'
|
||||
},
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
// No maxColumns — cols will be floor(400/200) = 2
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Same scroll position as the passing test
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
|
||||
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
|
||||
// The approach-end never fires at the correct scroll position
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="max-w-[384px]"
|
||||
class="max-w-96 flex-1"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
@@ -178,7 +178,7 @@
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
size="tall"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
@@ -318,6 +318,20 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
|
||||
>
|
||||
<template v-if="isAppTemplate(template)">
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
{{ $t('builderToolbar.app', 'App') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--workflow]" />
|
||||
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
@@ -389,7 +403,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
@@ -483,6 +497,8 @@ const {
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +71,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
@@ -94,6 +98,7 @@ function dismiss() {
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -26,15 +26,18 @@ function toggle() {
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0 sm:w-min sm:min-w-0"
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0',
|
||||
isExpanded ? 'sm:w-[max(400px,40vw)]' : 'sm:w-fit'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'max-w-full min-w-0 overflow-hidden transition-all duration-300',
|
||||
isExpanded
|
||||
? 'max-h-100 w-full sm:w-[max(400px,40vw)]'
|
||||
: 'max-h-0 w-0'
|
||||
isExpanded ? 'max-h-100 w-full' : 'max-h-0 w-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,73 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
// Combine our component props with PrimeVue MultiSelect props
|
||||
// Since we use v-bind="$attrs", all PrimeVue props are available
|
||||
interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
// Our custom props
|
||||
label?: string
|
||||
showSearchBox?: boolean
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
listMaxHeight?: string
|
||||
popoverMinWidth?: string
|
||||
popoverMaxWidth?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: SelectOption[]
|
||||
}
|
||||
|
||||
const meta: Meta<ExtendedProps> = {
|
||||
title: 'Components/Input/MultiSelect',
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
title: 'Components/Select/MultiSelect',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text'
|
||||
label: { control: 'text' },
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['lg', 'md']
|
||||
},
|
||||
options: {
|
||||
control: 'object'
|
||||
},
|
||||
showSearchBox: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle searchBar visibility'
|
||||
},
|
||||
showSelectedCount: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle selected count visibility'
|
||||
},
|
||||
showClearButton: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle clear button visibility'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: 'text'
|
||||
},
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of the dropdown list'
|
||||
},
|
||||
popoverMinWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width of the popover'
|
||||
},
|
||||
popoverMaxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width of the popover'
|
||||
}
|
||||
showSearchBox: { control: 'boolean' },
|
||||
showSelectedCount: { control: 'boolean' },
|
||||
showClearButton: { control: 'boolean' },
|
||||
searchPlaceholder: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
label: 'Select',
|
||||
options: [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
],
|
||||
label: 'Category',
|
||||
size: 'lg',
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
@@ -78,352 +38,125 @@ const meta: Meta<ExtendedProps> = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions: SelectOption[] = [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = args.options || [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
return { selected, options, args }
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" :label="args.label" :size="args.size" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: (args) => ({
|
||||
export const MediumSize: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = args.options || [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options, args }
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
|
||||
}),
|
||||
args: {
|
||||
label: 'Select Languages',
|
||||
options: [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const MultipleSelectors: Story = {
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0], sampleOptions[1]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
args: { showSearchBox: true },
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
])
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" />'
|
||||
})
|
||||
}
|
||||
|
||||
const projectOptions = ref([
|
||||
{ name: 'Project A', value: 'proj-a' },
|
||||
{ name: 'Project B', value: 'proj-b' },
|
||||
{ name: 'Project C', value: 'proj-c' },
|
||||
{ name: 'Project D', value: 'proj-d' }
|
||||
])
|
||||
export const AllHeaderFeatures: Story = {
|
||||
args: {
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
|
||||
})
|
||||
}
|
||||
|
||||
const tagOptions = ref([
|
||||
{ name: 'Frontend', value: 'frontend' },
|
||||
{ name: 'Backend', value: 'backend' },
|
||||
{ name: 'Database', value: 'database' },
|
||||
{ name: 'DevOps', value: 'devops' },
|
||||
{ name: 'Testing', value: 'testing' }
|
||||
])
|
||||
|
||||
const selectedFrameworks = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const selectedTags = ref([])
|
||||
|
||||
return {
|
||||
frameworkOptions,
|
||||
projectOptions,
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags,
|
||||
args
|
||||
}
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const a = ref<SelectOption[]>([])
|
||||
const b = ref<SelectOption[]>([sampleOptions[0]])
|
||||
const c = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { sampleOptions, a, b, c }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MultiSelect v-model="a" :options="sampleOptions" label="Default" />
|
||||
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" />
|
||||
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-base-background rounded">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MultiSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
|
||||
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" size="md" />
|
||||
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" size="md" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSelectedCount: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSelectedCount: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithClearButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AllHeaderFeatures: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
searchPlaceholder: 'Filter packages...'
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`
|
||||
}))
|
||||
return { selected1, selected2, selected3, manyOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected1"
|
||||
:options="manyOptions"
|
||||
label="Small Dropdown"
|
||||
list-max-height="10rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected2"
|
||||
:options="manyOptions"
|
||||
label="Default Dropdown"
|
||||
list-max-height="28rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected3"
|
||||
:options="manyOptions"
|
||||
label="Large Dropdown"
|
||||
list-max-height="32rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMinWidth: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const options = [
|
||||
{ name: 'A', value: 'a' },
|
||||
{ name: 'B', value: 'b' },
|
||||
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||
]
|
||||
return { selected1, selected2, selected3, options }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<MultiSelect v-model="selected1" :options="options" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
|
||||
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
|
||||
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxWidth: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const longOptions = [
|
||||
{ name: 'Short', value: 'short' },
|
||||
{
|
||||
name: 'This is a very long option name that would normally expand the dropdown',
|
||||
value: 'long1'
|
||||
},
|
||||
{
|
||||
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||
value: 'long2'
|
||||
}
|
||||
]
|
||||
return { selected1, selected2, selected3, longOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
|
||||
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
|
||||
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
controls: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,23 @@
|
||||
:pt="{
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'relative inline-flex h-10 cursor-pointer select-none',
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0
|
||||
? 'border-node-component-border'
|
||||
: 'border-transparent',
|
||||
'focus-within:border-node-component-border',
|
||||
{ 'cursor-default opacity-60': props.disabled }
|
||||
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
|
||||
'focus-within:border-base-foreground',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class:
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
|
||||
class: cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
@@ -93,13 +96,12 @@
|
||||
#header
|
||||
>
|
||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:show-order="true"
|
||||
:show-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
:placeholder="searchPlaceholder"
|
||||
size="sm"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
@@ -130,12 +132,12 @@
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm">
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
@@ -182,7 +184,7 @@ import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -198,6 +200,8 @@ defineOptions({
|
||||
interface Props {
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show search box in the panel header */
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
@@ -217,6 +221,7 @@ interface Props {
|
||||
}
|
||||
const {
|
||||
label,
|
||||
size = 'lg',
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
|
||||
219
src/components/input/SelectDropdown.stories.ts
Normal file
219
src/components/input/SelectDropdown.stories.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Select/SelectDropdown',
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const modelOptions: SelectOption[] = [
|
||||
{ name: 'ACE-Step', value: 'ace-step' },
|
||||
{ name: 'Anima', value: 'anima' },
|
||||
{ name: 'BRIA', value: 'bria' },
|
||||
{ name: 'ByteDance', value: 'bytedance' },
|
||||
{ name: 'Capybara', value: 'capybara' },
|
||||
{ name: 'Chatter Box', value: 'chatter-box' },
|
||||
{ name: 'Chroma', value: 'chroma' },
|
||||
{ name: 'ChronoEdit', value: 'chronoedit' },
|
||||
{ name: 'DWPose', value: 'dwpose' },
|
||||
{ name: 'Depth Anything v2', value: 'depth-anything-v2' },
|
||||
{ name: 'ElevenLabs', value: 'elevenlabs' },
|
||||
{ name: 'Flux', value: 'flux' },
|
||||
{ name: 'HunyuanVideo', value: 'hunyuan-video' },
|
||||
{ name: 'Stable Diffusion', value: 'stable-diffusion' },
|
||||
{ name: 'SDXL', value: 'sdxl' }
|
||||
]
|
||||
|
||||
const useCaseOptions: SelectOption[] = [
|
||||
{ name: 'Text to Image', value: 'text-to-image' },
|
||||
{ name: 'Image to Image', value: 'image-to-image' },
|
||||
{ name: 'Inpainting', value: 'inpainting' },
|
||||
{ name: 'Upscaling', value: 'upscaling' },
|
||||
{ name: 'Video Generation', value: 'video-generation' },
|
||||
{ name: 'Audio Generation', value: 'audio-generation' },
|
||||
{ name: '3D Generation', value: '3d-generation' }
|
||||
]
|
||||
|
||||
const sortOptions: SelectOption[] = [
|
||||
{ name: 'Default', value: 'default' },
|
||||
{ name: 'Recommended', value: 'recommended' },
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
|
||||
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
|
||||
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
|
||||
]
|
||||
|
||||
export const ModelFilter: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([
|
||||
modelOptions[1],
|
||||
modelOptions[2],
|
||||
modelOptions[3]
|
||||
])
|
||||
return { selected, modelOptions }
|
||||
},
|
||||
template: `
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="modelOptions"
|
||||
:label="selected.length === 0 ? 'Models' : selected.length === 1 ? selected[0].name : selected.length + ' Models'"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
class="w-[250px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const UseCaseFilter: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, useCaseOptions }
|
||||
},
|
||||
template: `
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="useCaseOptions"
|
||||
:label="selected.length === 0 ? 'Use Case' : selected.length === 1 ? selected[0].name : selected.length + ' Use Cases'"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--target]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const SortDropdown: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | undefined>('default')
|
||||
return { selected, sortOptions }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect
|
||||
v-model="selected"
|
||||
:options="sortOptions"
|
||||
label="Sort by"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const TemplateFilterBar: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect, SingleSelect },
|
||||
setup() {
|
||||
const selectedModels = ref<SelectOption[]>([
|
||||
modelOptions[1],
|
||||
modelOptions[2],
|
||||
modelOptions[3]
|
||||
])
|
||||
const selectedUseCases = ref<SelectOption[]>([])
|
||||
const sortBy = ref<string | undefined>('default')
|
||||
|
||||
const modelLabel = () => {
|
||||
if (selectedModels.value.length === 0) return 'Models'
|
||||
if (selectedModels.value.length === 1)
|
||||
return selectedModels.value[0].name
|
||||
return selectedModels.value.length + ' Models'
|
||||
}
|
||||
const useCaseLabel = () => {
|
||||
if (selectedUseCases.value.length === 0) return 'Use Case'
|
||||
if (selectedUseCases.value.length === 1)
|
||||
return selectedUseCases.value[0].name
|
||||
return selectedUseCases.value.length + ' Use Cases'
|
||||
}
|
||||
|
||||
return {
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
sortBy,
|
||||
modelOptions,
|
||||
useCaseOptions,
|
||||
sortOptions,
|
||||
modelLabel,
|
||||
useCaseLabel
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-wrap items-center justify-between gap-2" style="min-width: 700px;">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedModels"
|
||||
:options="modelOptions"
|
||||
:label="modelLabel()"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
class="w-[250px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<MultiSelect
|
||||
v-model="selectedUseCases"
|
||||
:options="useCaseOptions"
|
||||
:label="useCaseLabel()"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--target]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
label="Sort by"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
@@ -3,29 +3,31 @@ import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
// SingleSelect already includes options prop, so no need to extend
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
title: 'Components/Select/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
options: { control: 'object' },
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of the dropdown list'
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['lg', 'md']
|
||||
},
|
||||
popoverMinWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width of the popover'
|
||||
},
|
||||
popoverMaxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width of the popover'
|
||||
}
|
||||
invalid: { control: 'boolean' },
|
||||
loading: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
label: 'Sorting Type',
|
||||
label: 'Category',
|
||||
size: 'lg',
|
||||
invalid: false,
|
||||
loading: false,
|
||||
options: [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
@@ -37,7 +39,7 @@ const meta: Meta<typeof SingleSelect> = {
|
||||
}
|
||||
|
||||
export default meta
|
||||
export type Story = StoryObj<typeof meta>
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions = [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
@@ -52,205 +54,118 @@ export const Default: Story = {
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = args.options || sampleOptions
|
||||
return { selected, options, args }
|
||||
return { selected, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label" />
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="args.options" :label="args.label" :size="args.size" :invalid="args.invalid" :loading="args.loading" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SingleSelect v-model="selected" :options="sampleOptions" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] size-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
`
|
||||
})
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Preselected: Story = {
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('newest')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
|
||||
`
|
||||
})
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
export const Invalid: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const options = sampleOptions
|
||||
const a = ref<string | null>(null)
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" invalid />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" loading />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const a = ref<string | null>('popular')
|
||||
const b = ref<string | null>('popular')
|
||||
const c = ref<string | null>('az')
|
||||
return { options, a, b, c }
|
||||
const c = ref<string | null>('popular')
|
||||
const d = ref<string | null>('popular')
|
||||
const e = ref<string | null>('popular')
|
||||
return { sampleOptions, a, b, c, d, e }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="a" :options="options" label="No Icon" />
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<SingleSelect v-model="a" :options="sampleOptions" label="Default" />
|
||||
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" disabled />
|
||||
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" invalid />
|
||||
<SingleSelect v-model="d" :options="sampleOptions" label="Loading" loading />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="b" :options="options" label="With Icon">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<SingleSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
|
||||
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" size="md" disabled />
|
||||
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" size="md" invalid />
|
||||
<SingleSelect v-model="e" :options="sampleOptions" label="Loading" size="md" loading />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`
|
||||
}))
|
||||
return { selected, manyOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMinWidth: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected1 = ref<string | null>(null)
|
||||
const selected2 = ref<string | null>(null)
|
||||
const selected3 = ref<string | null>(null)
|
||||
const options = [
|
||||
{ name: 'A', value: 'a' },
|
||||
{ name: 'B', value: 'b' },
|
||||
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||
]
|
||||
return { selected1, selected2, selected3, options }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<SingleSelect v-model="selected1" :options="options" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
|
||||
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
|
||||
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxWidth: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected1 = ref<string | null>(null)
|
||||
const selected2 = ref<string | null>(null)
|
||||
const selected3 = ref<string | null>(null)
|
||||
const longOptions = [
|
||||
{ name: 'Short', value: 'short' },
|
||||
{
|
||||
name: 'This is a very long option name that would normally expand the dropdown',
|
||||
value: 'long1'
|
||||
},
|
||||
{
|
||||
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||
value: 'long2'
|
||||
}
|
||||
]
|
||||
return { selected1, selected2, selected3, longOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
|
||||
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
|
||||
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
controls: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,26 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: [
|
||||
// container
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
class: cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-node-component-border',
|
||||
// disabled
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
]
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus-within:border-node-component-border',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
class: cn(
|
||||
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
@@ -77,6 +80,8 @@
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
@@ -84,8 +89,16 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="loading"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-base-foreground"
|
||||
@@ -98,9 +111,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<!-- Trigger caret (hidden when loading) -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="icon-[lucide--chevron-down] text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
@@ -133,6 +149,9 @@ defineOptions({
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
loading = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
@@ -144,6 +163,12 @@ const {
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show invalid (destructive) border */
|
||||
invalid?: boolean
|
||||
/** Show loading spinner instead of chevron */
|
||||
loading?: boolean
|
||||
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||
listMaxHeight?: string
|
||||
/** Minimum width of the popover (default: auto) */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-if="showSearch"
|
||||
:model-value="searchQuery"
|
||||
class="min-w-0 flex-1"
|
||||
@@ -116,7 +116,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobSortModes } from '@/composables/queue/useJobList'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, reactive, ref, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
searchQuery: MaybeRefOrGetter<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -584,7 +584,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
const query = toValue(searchQuery).trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="listGridStyle"
|
||||
:max-columns="1"
|
||||
:default-item-height="48"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
class="workflows-search-box"
|
||||
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
:placeholder="
|
||||
@@ -56,7 +56,7 @@
|
||||
import { Divider } from 'primevue'
|
||||
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
|
||||
@@ -86,18 +86,40 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
data-testid="node-library-search"
|
||||
class="node-lib-search-box"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
data-testid="node-library-search"
|
||||
class="node-lib-search-box"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
|
||||
"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="filter-button shrink-0"
|
||||
:aria-label="$t('g.filter')"
|
||||
@click="(e: Event) => searchFilter?.toggle(e)"
|
||||
>
|
||||
<i class="pi pi-filter" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filters?.length"
|
||||
class="search-filters flex flex-wrap gap-2 pt-2"
|
||||
>
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="onRemoveFilter(filter)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
@@ -155,8 +177,9 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchBox',
|
||||
template: '<input data-testid="search-box" />',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template #header>
|
||||
<TabsRoot v-model="selectedTab" class="flex flex-col">
|
||||
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@@ -180,7 +180,7 @@ import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
@@ -253,7 +253,7 @@ const filterOptions = ref<Record<NodeCategoryId, boolean>>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
|
||||
const searchBoxRef = ref<InstanceType<typeof SearchInput> | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
essentials: [],
|
||||
|
||||
@@ -9,6 +9,18 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: false
|
||||
}))
|
||||
|
||||
const mockTeamWorkspaceStore = vi.hoisted(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' },
|
||||
isInPersonalWorkspace: { value: false }
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
@@ -32,16 +44,19 @@ vi.mock('pinia', () => ({
|
||||
// Mock the useFeatureFlags composable
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: { teamWorkspacesEnabled: false }
|
||||
flags: mockFeatureFlags
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useTeamWorkspaceStore
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: vi.fn(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' }
|
||||
}))
|
||||
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
@@ -64,6 +79,16 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the WorkspaceProfilePic component
|
||||
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkspaceProfilePicMock',
|
||||
render() {
|
||||
return h('div', 'WorkspaceProfilePic')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
@@ -78,9 +103,15 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
describe('CurrentUserButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = ''
|
||||
mockTeamWorkspaceStore.initState.value = 'idle'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
|
||||
const { stubButton = true } = options ?? {}
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -99,7 +130,7 @@ describe('CurrentUserButton', () => {
|
||||
hide: vi.fn()
|
||||
}
|
||||
},
|
||||
Button: true
|
||||
...(stubButton ? { Button: true } : {})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -137,4 +168,27 @@ describe('CurrentUserButton', () => {
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows UserAvatar in personal workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('Avatar')
|
||||
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
|
||||
})
|
||||
|
||||
it('shows WorkspaceProfilePic in team workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('WorkspaceProfilePic')
|
||||
expect(wrapper.html()).not.toContain('Avatar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,15 +98,21 @@ const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
const {
|
||||
workspaceName: teamWorkspaceName,
|
||||
initState,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(useTeamWorkspaceStore())
|
||||
|
||||
const showWorkspaceSkeleton = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
|
||||
)
|
||||
const showWorkspaceIcon = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
|
||||
() =>
|
||||
isCloud &&
|
||||
teamWorkspacesEnabled.value &&
|
||||
initState.value === 'ready' &&
|
||||
!isInPersonalWorkspace.value
|
||||
)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
|
||||
|
||||
let dragging = false
|
||||
function handleDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (e.button !== 0 && e.button !== 1) return
|
||||
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
|
||||
202
src/components/ui/search-input/SearchInput.test.ts
Normal file
202
src/components/ui/search-input/SearchInput.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from './SearchInput.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: vi.fn((source, cb, opts) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
return watch(source, (val: string) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => cb(val), opts?.debounce ?? 300)
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
clear: 'Clear',
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchInput, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: {
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
ComboboxAnchor: {
|
||||
template: '<div @click="$emit(\'click\')"><slot /></div>',
|
||||
emits: ['click']
|
||||
},
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :placeholder="placeholder" :value="modelValue" :autofocus="autoFocus || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['placeholder', 'modelValue', 'autoFocus']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: '',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('debounced search', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('test')
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toEqual([['test']])
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('t')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
expect(input.element.value).toBe('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
})
|
||||
|
||||
it('should use i18n placeholder when not provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should pass autofocus prop to ComboboxInput', () => {
|
||||
const wrapper = mountComponent({ autofocus: true })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not autofocus by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('focus method', () => {
|
||||
it('should expose focus method via ref', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.vm.focus).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear button', () => {
|
||||
it('shows search icon when value is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows clear button when value is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears value when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
|
||||
<ComboboxRoot
|
||||
:ignore-filter="true"
|
||||
:open="false"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
searchInputVariants({ size }),
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
disabled && 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
@click="focus"
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Select from './Select.vue'
|
||||
import SelectContent from './SelectContent.vue'
|
||||
import SelectGroup from './SelectGroup.vue'
|
||||
import SelectItem from './SelectItem.vue'
|
||||
import SelectLabel from './SelectLabel.vue'
|
||||
import SelectSeparator from './SelectSeparator.vue'
|
||||
import SelectTrigger from './SelectTrigger.vue'
|
||||
import SelectValue from './SelectValue.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'Selected value'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'When true, disables the select'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
} satisfies Meta<typeof Select>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref(args.modelValue || '')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
<SelectItem value="grape">Grape</SelectItem>
|
||||
<SelectItem value="orange">Orange</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Choose an option..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('apple')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" disabled>
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithGroups: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a model type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Checkpoints</SelectLabel>
|
||||
<SelectItem value="sd15">SD 1.5</SelectItem>
|
||||
<SelectItem value="sdxl">SDXL</SelectItem>
|
||||
<SelectItem value="flux">Flux</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>LoRAs</SelectLabel>
|
||||
<SelectItem value="lora-style">Style LoRA</SelectItem>
|
||||
<SelectItem value="lora-character">Character LoRA</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other</SelectLabel>
|
||||
<SelectItem value="vae">VAE</SelectItem>
|
||||
<SelectItem value="embedding">Embedding</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Scrollable: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: `item-${i + 1}`,
|
||||
label: `Option ${i + 1}`
|
||||
}))
|
||||
return { value, items, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-32">
|
||||
<SelectValue placeholder="Small" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">A</SelectItem>
|
||||
<SelectItem value="b">B</SelectItem>
|
||||
<SelectItem value="c">C</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Full width select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectGroupProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectLabelProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'px-3 py-2 text-xs tracking-wide text-muted-foreground uppercase',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
v-bind="restProps"
|
||||
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
|
||||
/>
|
||||
</template>
|
||||
@@ -5,24 +5,41 @@ import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
const {
|
||||
class: className,
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
SelectTriggerProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
/** Trigger size: 'lg' (40px) or 'md' (32px) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show invalid (destructive) border */
|
||||
invalid?: boolean
|
||||
}
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="restProps"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full cursor-pointer items-center justify-between select-none',
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
'flex w-full cursor-pointer items-center justify-between select-none',
|
||||
size === 'md' ? 'h-8 px-3 py-1 text-xs' : 'h-10 px-4 py-2 text-sm',
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus:border-node-component-border',
|
||||
'focus:outline-none',
|
||||
'data-placeholder:text-muted-foreground',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-secondary-background',
|
||||
'[&>span]:truncate',
|
||||
className
|
||||
)
|
||||
|
||||
77
src/components/ui/slider/Slider.stories.ts
Normal file
77
src/components/ui/slider/Slider.stories.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
|
||||
import Slider from './Slider.vue'
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof Slider> {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-72"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const { min, max, step, disabled } = toRefs(args)
|
||||
const value = ref([36])
|
||||
const display = computed(() => value.value[0])
|
||||
return { value, display, min, max, step, disabled }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
|
||||
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
|
||||
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const { min, max, step, disabled } = toRefs(args)
|
||||
const value = ref([36])
|
||||
const display = computed(() => value.value[0])
|
||||
return { value, display, min, max, step, disabled }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
|
||||
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
|
||||
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||
<SearchInput v-model="searchQuery" size="lg" class="max-w-96 flex-1" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -130,7 +130,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -8,7 +8,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
@@ -68,7 +68,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseModalLayout,
|
||||
LeftSidePanel,
|
||||
SearchBox,
|
||||
SearchInput,
|
||||
MultiSelect,
|
||||
SingleSelect,
|
||||
Button,
|
||||
@@ -186,7 +186,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
@@ -309,7 +309,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Header -->
|
||||
<template v-if="args.hasHeader" #header>
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
class="max-w-[384px]"
|
||||
size="lg"
|
||||
:modelValue="searchQuery"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -29,7 +28,6 @@ useExtensionService().registerExtension({
|
||||
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
appendCloudResParam(params, record.filename)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -3180,6 +3180,7 @@
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3224,6 +3225,19 @@
|
||||
"outputPlaceholder": "Output nodes will show up here",
|
||||
"outputRequiredPlaceholder": "At least one node is required"
|
||||
},
|
||||
"error": {
|
||||
"header": "This app encountered an error",
|
||||
"log": "Error Logs",
|
||||
"mobileFixable": "Check {0} for errors",
|
||||
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
|
||||
"promptVisitGraph": "View the node graph to see the full error.",
|
||||
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
|
||||
"goto": "Show errors in graph",
|
||||
"github": "submit a GitHub issue",
|
||||
"guide": "troubleshooting guide",
|
||||
"support": "contact our support",
|
||||
"promptShow": "Show error report"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
class="flex w-full items-center justify-between gap-2"
|
||||
@click.self="focusedAsset = null"
|
||||
>
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:autofocus="true"
|
||||
size="lg"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
|
||||
class="max-w-96"
|
||||
class="max-w-lg flex-1"
|
||||
/>
|
||||
<Button
|
||||
v-if="isUploadButtonEnabled"
|
||||
@@ -88,7 +88,7 @@ import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
|
||||
@@ -236,7 +236,7 @@ const adaptedAsset = computed(() => {
|
||||
name: asset.name,
|
||||
display_name: asset.display_name,
|
||||
kind: fileKind.value,
|
||||
src: asset.preview_url || '',
|
||||
src: asset.thumbnail_url || asset.preview_url || '',
|
||||
size: asset.size,
|
||||
tags: asset.tags || [],
|
||||
created_at: asset.created_at,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('sideToolbar.labels.assets') })
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
|
||||
@@ -45,7 +45,8 @@ export function mapTaskOutputToAssetItem(
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.previewUrl,
|
||||
thumbnail_url: output.previewUrl,
|
||||
preview_url: output.url,
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
@@ -63,6 +64,7 @@ export function mapInputFileToAssetItem(
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
const params = new URLSearchParams({ filename, type: directory })
|
||||
const preview_url = api.apiURL(`/view?${params}`)
|
||||
appendCloudResParam(params, filename)
|
||||
|
||||
return {
|
||||
@@ -71,6 +73,7 @@ export function mapInputFileToAssetItem(
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
preview_url: api.apiURL(`/view?${params}`)
|
||||
thumbnail_url: api.apiURL(`/view?${params}`),
|
||||
preview_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const zAsset = z.object({
|
||||
preview_id: z.string().nullable().optional(),
|
||||
display_name: z.string().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
is_immutable: z.boolean().optional(),
|
||||
|
||||
@@ -73,7 +73,8 @@ function mapOutputsToAssetItems({
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
preview_url: output.previewUrl,
|
||||
thumbnail_url: output.previewUrl,
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: output.nodeId,
|
||||
|
||||
@@ -31,7 +31,19 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
path: 'signup',
|
||||
name: 'cloud-signup',
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudSignupView.vue')
|
||||
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
|
||||
beforeEnter: async (to, _from, next) => {
|
||||
if (!to.query.switchAccount) {
|
||||
const { useCurrentUser } =
|
||||
await import('@/composables/auth/useCurrentUser')
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next({ name: 'cloud-user-check' })
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="extension-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.extensions') })"
|
||||
/>
|
||||
@@ -92,7 +92,7 @@ import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<template #leftPanel>
|
||||
<div class="px-3">
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model:model-value="searchQuery"
|
||||
size="md"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
@@ -71,7 +71,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
onThumbnailError($event.name, $event.previewUrl)
|
||||
"
|
||||
/>
|
||||
<span class="truncate text-xs text-base-foreground">
|
||||
<span
|
||||
v-tooltip="buildTooltipConfig(item.name)"
|
||||
class="truncate text-xs text-base-foreground"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
@@ -74,6 +77,7 @@ import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAss
|
||||
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
const { items } = defineProps<{
|
||||
items: AssetInfo[]
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<SearchBox
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
@@ -367,7 +367,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
@@ -10,6 +10,7 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
@@ -29,7 +30,7 @@ import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -63,21 +64,41 @@ useEventListener(
|
||||
)
|
||||
|
||||
const mappedSelections = computed(() => {
|
||||
let unprocessedInputs = [...appModeStore.selectedInputs]
|
||||
//FIXME strict typing here
|
||||
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
|
||||
([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return widget ? ([[node, widget]] as const) : []
|
||||
}
|
||||
)
|
||||
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
|
||||
while (unprocessedInputs.length) {
|
||||
const nodeId = unprocessedInputs[0][0]
|
||||
const inputGroup = takeWhile(
|
||||
unprocessedInputs,
|
||||
([id]) => id === nodeId
|
||||
).map(([, widgetName]) => widgetName)
|
||||
const [node] = unprocessedInputs[0]
|
||||
const inputGroup = takeWhile(unprocessedInputs, ([n]) => n === node).map(
|
||||
([, widget]) => widget
|
||||
)
|
||||
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
|
||||
const node = resolveNode(nodeId)
|
||||
//FIXME: hide widget if owning node bypassed
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS) continue
|
||||
|
||||
const nodeData = nodeToNodeData(node)
|
||||
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
|
||||
remove(nodeData.widgets ?? [], (vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return true
|
||||
|
||||
if (!node.isSubgraphNode())
|
||||
return !inputGroup.some((w) => w.name === vueWidget.name)
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return !inputGroup.some(
|
||||
(subWidget) =>
|
||||
isPromotedWidgetView(subWidget) &&
|
||||
subWidget.sourceNodeId == storeNodeId &&
|
||||
subWidget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
})
|
||||
for (const widget of nodeData.widgets ?? []) {
|
||||
widget.slotMetadata = undefined
|
||||
widget.nodeId = String(node.id)
|
||||
}
|
||||
processedInputs.push(nodeData)
|
||||
}
|
||||
return processedInputs
|
||||
@@ -107,8 +128,6 @@ function getDropIndicator(node: LGraphNode) {
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator = getDropIndicator(node)
|
||||
const nodeData = extractVueNodeData(node)
|
||||
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
|
||||
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
|
||||
@@ -15,7 +15,9 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import MobileError from '@/renderer/extensions/linearMode/MobileError.vue'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
@@ -31,6 +33,7 @@ const canvasStore = useCanvasStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { t } = useI18n()
|
||||
const { commandIdToMenuItem } = useMenuItemStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -40,7 +43,7 @@ const { toggle: toggleFullscreen } = useFullscreen(undefined, {
|
||||
autoExit: true
|
||||
})
|
||||
|
||||
const activeIndex = ref(2)
|
||||
const activeIndex = ref(1)
|
||||
const sliderPaneRef = useTemplateRef('sliderPaneRef')
|
||||
const sliderWidth = computed(() => sliderPaneRef.value?.offsetWidth)
|
||||
|
||||
@@ -192,7 +195,11 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
<div
|
||||
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
|
||||
>
|
||||
<LinearPreview mobile />
|
||||
<MobileError
|
||||
v-if="executionErrorStore.isErrorOverlayOpen"
|
||||
@navigate-controls="activeIndex = 0"
|
||||
/>
|
||||
<LinearPreview v-else mobile @navigate-controls="activeIndex = 0" />
|
||||
</div>
|
||||
<AssetsSidebarTab
|
||||
class="absolute top-0 left-[200vw] h-full w-screen bg-base-background"
|
||||
@@ -213,7 +220,11 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
<div class="relative size-4">
|
||||
<i :class="cn('size-4', icon)" />
|
||||
<div
|
||||
v-if="
|
||||
v-if="index === 1 && executionErrorStore.isErrorOverlayOpen"
|
||||
class="absolute -top-1 -right-1 size-2 rounded-full bg-error"
|
||||
/>
|
||||
<div
|
||||
v-else-if="
|
||||
index === 1 &&
|
||||
(queueStore.runningTasks.length > 0 ||
|
||||
queueStore.pendingTasks.length > 0)
|
||||
|
||||
174
src/renderer/extensions/linearMode/MobileError.vue
Normal file
174
src/renderer/extensions/linearMode/MobileError.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Dialogue from '@/components/common/Dialogue.vue'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
defineEmits<{ navigateControls: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { buildDocsUrl, staticUrls } = useExternalLink()
|
||||
const { allErrorGroups } = useErrorGroups('', t)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const guideUrl = buildDocsUrl('troubleshooting/overview', {
|
||||
includeLocale: true
|
||||
})
|
||||
const supportUrl = buildSupportUrl()
|
||||
|
||||
const inputNodeIds = computed(() => {
|
||||
const ids = new Set()
|
||||
for (const [id] of appModeStore.selectedInputs) ids.add(String(id))
|
||||
return ids
|
||||
})
|
||||
|
||||
const accessibleNodeErrors = computed(() =>
|
||||
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
|
||||
inputNodeIds.value.has(k)
|
||||
)
|
||||
)
|
||||
const accessibleErrors = computed(() =>
|
||||
accessibleNodeErrors.value.flatMap((k) =>
|
||||
executionErrorStore.lastNodeErrors![k].errors.flatMap((error) => {
|
||||
const { extra_info } = error
|
||||
if (!extra_info) return []
|
||||
|
||||
const selectedInput = appModeStore.selectedInputs.find(
|
||||
([id, name]) => id == k && extra_info.input_name === name
|
||||
)
|
||||
if (!selectedInput) return []
|
||||
|
||||
return [`${selectedInput[1]}: ${error.message}`]
|
||||
})
|
||||
)
|
||||
)
|
||||
const allErrors = computed(() =>
|
||||
allErrorGroups.value.flatMap((group) => {
|
||||
if (group.type !== 'execution') return [group.title]
|
||||
|
||||
return group.cards.flatMap((c) =>
|
||||
c.errors.map((e) =>
|
||||
e.details
|
||||
? `${c.title} (${e.details}): ${e.message}`
|
||||
: `${c.title}: ${e.message}`
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
function copy(obj: unknown) {
|
||||
copyToClipboard(JSON.stringify(obj))
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="flex h-full flex-col items-center justify-center gap-2 px-4">
|
||||
<i class="icon-[lucide--circle-alert] size-6 bg-error" />
|
||||
{{ t('linearMode.error.header') }}
|
||||
<div class="p-1 text-muted-foreground">
|
||||
<i18n-t
|
||||
v-if="accessibleErrors.length"
|
||||
keypath="linearMode.error.mobileFixable"
|
||||
>
|
||||
<Button @click="$emit('navigateControls')">
|
||||
{{ t('linearMode.mobileControls') }}
|
||||
</Button>
|
||||
</i18n-t>
|
||||
<div v-else class="text-center">
|
||||
<p v-text="t('linearMode.error.requiresGraph')" />
|
||||
<p v-text="t('linearMode.error.promptVisitGraph')" />
|
||||
<p class="*:text-muted-foreground">
|
||||
<i18n-t keypath="linearMode.error.getHelp">
|
||||
<a
|
||||
:href="guideUrl"
|
||||
target="_blank"
|
||||
v-text="t('linearMode.error.guide')"
|
||||
/>
|
||||
<a
|
||||
:href="staticUrls.githubIssues"
|
||||
target="_blank"
|
||||
v-text="t('linearMode.error.github')"
|
||||
/>
|
||||
<a
|
||||
:href="supportUrl"
|
||||
target="_blank"
|
||||
v-text="t('linearMode.error.support')"
|
||||
/>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<Dialogue :title="t('linearMode.error.log')">
|
||||
<template #button>
|
||||
<Button variant="textonly">
|
||||
{{ t('linearMode.error.promptShow') }}
|
||||
<i class="icon-[lucide--chevron-right] size-5" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<article class="flex flex-col gap-2 p-4">
|
||||
<section class="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="error in allErrors"
|
||||
:key="error"
|
||||
class="w-full rounded-lg bg-secondary-background p-2 text-muted-foreground"
|
||||
v-text="error"
|
||||
/>
|
||||
</section>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<Button variant="muted-textonly" size="lg" @click="close">
|
||||
{{ t('g.close') }}
|
||||
</Button>
|
||||
<Button size="lg" @click="copy(allErrors)">
|
||||
{{ t('importFailed.copyError') }}
|
||||
<i class="icon-[lucide--copy]" />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Dialogue>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="accessibleErrors.length"
|
||||
class="my-8 w-full rounded-lg bg-secondary-background text-muted-foreground"
|
||||
>
|
||||
<ul>
|
||||
<li
|
||||
v-for="error in accessibleErrors"
|
||||
:key="error"
|
||||
class="before:content"
|
||||
v-text="error"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="executionErrorStore.dismissErrorOverlay()"
|
||||
>
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="textonly" size="lg" @click="setMode('graph')">
|
||||
{{ t('linearMode.viewGraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="accessibleErrors.length"
|
||||
size="lg"
|
||||
@click="copy(accessibleErrors)"
|
||||
>
|
||||
{{ t('importFailed.copyError') }}
|
||||
<i class="icon-[lucide--copy]" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<div :class="cn(WidgetInputBaseClass, 'flex items-center gap-2 pr-2 pl-3')">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'flex items-center gap-2 pr-2 pl-3 not-disabled:hover:bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[modelValue]"
|
||||
v-bind="filteredProps"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import FormDropdown from './FormDropdown.vue'
|
||||
import type { FormDropdownItem } from './types'
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
return { id, preview_url: '', name, label: name }
|
||||
}
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
addAlert: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const MockFormDropdownMenu = defineComponent({
|
||||
name: 'FormDropdownMenu',
|
||||
props: {
|
||||
items: { type: Array as () => FormDropdownItem[], default: () => [] },
|
||||
isSelected: { type: Function, default: undefined },
|
||||
filterOptions: { type: Array, default: () => [] },
|
||||
sortOptions: { type: Array, default: () => [] },
|
||||
maxSelectable: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
showOwnershipFilter: { type: Boolean, default: false },
|
||||
ownershipOptions: { type: Array, default: () => [] },
|
||||
showBaseModelFilter: { type: Boolean, default: false },
|
||||
baseModelOptions: { type: Array, default: () => [] }
|
||||
},
|
||||
setup() {
|
||||
return () => h('div', { class: 'mock-menu' })
|
||||
}
|
||||
})
|
||||
|
||||
function mountDropdown(items: FormDropdownItem[]) {
|
||||
return mount(FormDropdown, {
|
||||
props: { items },
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
stubs: {
|
||||
FormDropdownInput: true,
|
||||
Popover: { template: '<div><slot /></div>' },
|
||||
FormDropdownMenu: MockFormDropdownMenu
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getMenuItems(
|
||||
wrapper: ReturnType<typeof mountDropdown>
|
||||
): FormDropdownItem[] {
|
||||
return wrapper
|
||||
.findComponent(MockFormDropdownMenu)
|
||||
.props('items') as FormDropdownItem[]
|
||||
}
|
||||
|
||||
describe('FormDropdown', () => {
|
||||
describe('filteredItems updates when items prop changes', () => {
|
||||
it('updates displayed items when items prop changes', async () => {
|
||||
const wrapper = mountDropdown([
|
||||
createItem('input-0', 'video1.mp4'),
|
||||
createItem('input-1', 'video2.mp4')
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(2)
|
||||
|
||||
await wrapper.setProps({
|
||||
items: [
|
||||
createItem('output-0', 'rendered1.mp4'),
|
||||
createItem('output-1', 'rendered2.mp4')
|
||||
]
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const menuItems = getMenuItems(wrapper)
|
||||
expect(menuItems).toHaveLength(2)
|
||||
expect(menuItems[0].name).toBe('rendered1.mp4')
|
||||
})
|
||||
|
||||
it('updates when items change but IDs stay the same', async () => {
|
||||
const wrapper = mountDropdown([createItem('1', 'alpha')])
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ items: [createItem('1', 'beta')] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)[0].name).toBe('beta')
|
||||
})
|
||||
|
||||
it('updates when switching between empty and non-empty items', async () => {
|
||||
const wrapper = mountDropdown([])
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(0)
|
||||
|
||||
await wrapper.setProps({ items: [createItem('1', 'video.mp4')] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(1)
|
||||
expect(getMenuItems(wrapper)[0].name).toBe('video.mp4')
|
||||
|
||||
await wrapper.setProps({ items: [] })
|
||||
await flushPromises()
|
||||
|
||||
expect(getMenuItems(wrapper)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync, refDebounced } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -101,9 +102,16 @@ const maxSelectable = computed(() => {
|
||||
return 1
|
||||
})
|
||||
|
||||
const itemsKey = computed(() => items.map((item) => item.id).join('|'))
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
|
||||
|
||||
const filteredItems = ref<FormDropdownItem[]>([])
|
||||
const filteredItems = computedAsync(async (onCancel) => {
|
||||
let cleanupFn: (() => void) | undefined
|
||||
onCancel(() => cleanupFn?.())
|
||||
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
|
||||
cleanupFn = cb
|
||||
})
|
||||
return result
|
||||
}, [])
|
||||
|
||||
const defaultSorter = computed<SortOption['sorter']>(() => {
|
||||
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
|
||||
@@ -171,21 +179,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
async function customSearcher(
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
await searcher(query, items, (cb) => (cleanupFn = cb)).then((results) => {
|
||||
if (!isCleanup) filteredItems.value = results
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -233,11 +226,9 @@ async function customSearcher(
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
:disabled
|
||||
:searcher="customSearcher"
|
||||
:items="sortedItems"
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable
|
||||
:update-key="itemsKey"
|
||||
@close="closeDropdown"
|
||||
@item-click="handleSelection"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
@@ -20,11 +20,6 @@ interface Props {
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
updateKey?: MaybeRefOrGetter<unknown>
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -36,8 +31,6 @@ const {
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
searcher,
|
||||
updateKey,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -118,8 +111,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:sort-options
|
||||
:searcher
|
||||
:update-key
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -19,12 +17,7 @@ import type { LayoutMode, SortOption } from './types'
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
sortOptions: SortOption[]
|
||||
updateKey?: MaybeRefOrGetter<unknown>
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -108,8 +101,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
<div class="text-secondary flex gap-2 px-4">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
|
||||
@@ -109,8 +109,15 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
|
||||
// Spy on the addWidget method
|
||||
vi.spyOn(node, 'addWidget').mockImplementation(
|
||||
(type, name, value, callback) => {
|
||||
const widget = createMockWidget({ type, name, value })
|
||||
(type, name, value, callback, options = {}) => {
|
||||
const normalizedOptions =
|
||||
typeof options === 'string' ? { property: options } : options
|
||||
const widget = createMockWidget({
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options: normalizedOptions
|
||||
})
|
||||
// Store the callback function on the widget for testing
|
||||
if (typeof callback === 'function') {
|
||||
widget.callback = callback
|
||||
@@ -320,7 +327,7 @@ describe('useComboWidget', () => {
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
values: [], // Empty initially, populated dynamically by Proxy
|
||||
values: [], // Empty initially, populated via dynamic getter
|
||||
getOptionLabel: expect.any(Function)
|
||||
})
|
||||
)
|
||||
@@ -328,6 +335,23 @@ describe('useComboWidget', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('should keep the original options object for cloud input mappings', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
|
||||
const options = addWidgetCall[4]
|
||||
|
||||
expect(widget.options).toBe(options)
|
||||
})
|
||||
|
||||
it("should format option labels using store's getInputName function", () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
|
||||
|
||||
@@ -44,6 +44,29 @@ const NODE_PLACEHOLDER_MAP: Record<string, string> = {
|
||||
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
|
||||
}
|
||||
|
||||
const bindDynamicValuesOption = (
|
||||
widget: IBaseWidget,
|
||||
getValues: () => unknown
|
||||
) => {
|
||||
const options = widget.options
|
||||
let fallbackValues = Array.isArray(options.values)
|
||||
? options.values
|
||||
: ([] as unknown[])
|
||||
|
||||
Object.defineProperty(options, 'values', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
const values = getValues()
|
||||
if (values === undefined || values === null) return fallbackValues
|
||||
return values
|
||||
},
|
||||
set: (values: unknown[]) => {
|
||||
fallbackValues = Array.isArray(values) ? values : fallbackValues
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addMultiSelectWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
@@ -133,22 +156,16 @@ const createInputMappingWidget = (
|
||||
})
|
||||
}
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
if (prop !== 'values') {
|
||||
return target[prop as keyof typeof target]
|
||||
}
|
||||
return assetsStore.inputAssets
|
||||
.filter(
|
||||
(asset) =>
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
}
|
||||
})
|
||||
bindDynamicValuesOption(widget, () =>
|
||||
assetsStore.inputAssets
|
||||
.filter(
|
||||
(asset) =>
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
)
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
if (!isComboWidget(widget)) {
|
||||
@@ -210,15 +227,7 @@ const addComboWidget = (
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
// Assertion: Proxy handler passthrough
|
||||
return prop !== 'values'
|
||||
? target[prop as keyof typeof target]
|
||||
: remoteWidget.getValue()
|
||||
}
|
||||
})
|
||||
bindDynamicValuesOption(widget, () => remoteWidget.getValue())
|
||||
}
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
|
||||
@@ -208,6 +208,11 @@ export class ComfyApp {
|
||||
return this.rootGraphInternal!
|
||||
}
|
||||
|
||||
/** Whether the root graph has been initialized. Safe to check without triggering error logs. */
|
||||
get isGraphReady(): boolean {
|
||||
return !!this.rootGraphInternal
|
||||
}
|
||||
|
||||
canvas!: LGraphCanvas
|
||||
dragOverNode: LGraphNode | null = null
|
||||
readonly canvasElRef = shallowRef<HTMLCanvasElement>()
|
||||
@@ -1825,7 +1830,6 @@ export class ComfyApp {
|
||||
)
|
||||
if (missingNodeTypes.length) {
|
||||
this.showMissingNodesError(missingNodeTypes.map((t) => t.class_type))
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(apiData)
|
||||
|
||||
@@ -238,7 +238,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
/** Graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
if (!app.isGraphReady) return ids
|
||||
|
||||
// Fall back to rootGraph when currentGraph hasn't been initialized yet
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
@@ -287,7 +287,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
if (!app.isGraphReady) return ids
|
||||
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
|
||||
@@ -357,7 +357,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
/** True if the node has errors inside it at any nesting depth. */
|
||||
function isContainerWithInternalError(node: LGraphNode): boolean {
|
||||
if (!app.rootGraph) return false
|
||||
if (!app.isGraphReady) return false
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return false
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
@@ -365,15 +365,15 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
/** True if the node has a missing node inside it at any nesting depth. */
|
||||
function isContainerWithMissingNode(node: LGraphNode): boolean {
|
||||
if (!app.rootGraph) return false
|
||||
if (!app.isGraphReady) return false
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return false
|
||||
return missingAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
watch(lastNodeErrors, () => {
|
||||
if (!app.isGraphReady) return
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return
|
||||
|
||||
clearAllNodeErrorFlags(rootGraph)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { clone } from '@/scripts/utils'
|
||||
@@ -120,11 +119,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
const isImage = isImageOutputs(node, outputs)
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
if (isImage) appendCloudResParam(params, image.filename)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import {
|
||||
@@ -319,6 +320,31 @@ export function resolveNode(
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
export function resolveNodeWidget(
|
||||
nodeId: NodeId,
|
||||
widgetName?: string,
|
||||
graph: LGraph = app.rootGraph
|
||||
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!widgetName) return node ? [node] : []
|
||||
if (node) {
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
return widget ? [node, widget] : []
|
||||
}
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.isSubgraphNode()) continue
|
||||
const widget = node.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
if (widget) return [node, widget]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function isLoad3dNode(node: LGraphNode) {
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -156,6 +157,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
@@ -245,8 +245,10 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex w-full items-center justify-between px-6 py-2 shadow-lg">
|
||||
<div class="flex items-center text-base leading-none">
|
||||
<div
|
||||
class="flex w-full items-center justify-between gap-4 px-6 py-2 shadow-lg"
|
||||
>
|
||||
<div class="flex min-w-0 items-center text-base leading-none">
|
||||
<div class="flex items-center">
|
||||
<template v-if="isInProgress">
|
||||
<DotSpinner duration="1s" class="mr-2" />
|
||||
@@ -262,7 +264,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex shrink-0 items-center gap-4">
|
||||
<span v-if="isInProgress" class="text-sm text-muted-foreground">
|
||||
{{ completedTasksCount }} {{ t('g.progressCountOf') }}
|
||||
{{ totalTasksCount }}
|
||||
|
||||
Reference in New Issue
Block a user