mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 09:57:33 +00:00
Compare commits
19 Commits
fix/load-a
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed9a379c6 | ||
|
|
dc3e455993 | ||
|
|
76006fca52 | ||
|
|
d2792cfac6 | ||
|
|
a786825093 | ||
|
|
b0f3b69bda | ||
|
|
d11a0f6c5e | ||
|
|
f97c38e6ee | ||
|
|
e89a0f96cd | ||
|
|
12989e8b63 | ||
|
|
c084605e4d | ||
|
|
b368a865cf | ||
|
|
1d7a5b9e0b | ||
|
|
fbcd36d355 | ||
|
|
e594164b71 | ||
|
|
245f840e7c | ||
|
|
240b54419b | ||
|
|
d9020b7fbe | ||
|
|
c4272ef1da |
@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
14
.github/workflows/cloud-dispatch-build.yaml
vendored
14
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -46,18 +46,30 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
ACTION: ${{ github.event.action }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
|
||||
run: |
|
||||
if [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
REF="${PR_HEAD_SHA}"
|
||||
BRANCH="${PR_HEAD_REF}"
|
||||
|
||||
# Derive variant from all PR labels (default to cpu for frontend-only previews)
|
||||
VARIANT="cpu"
|
||||
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
|
||||
else
|
||||
REF="${GITHUB_SHA}"
|
||||
BRANCH="${GITHUB_REF_NAME}"
|
||||
PR_NUMBER=""
|
||||
VARIANT=""
|
||||
fi
|
||||
payload="$(jq -nc \
|
||||
--arg ref "${REF}" \
|
||||
--arg branch "${BRANCH}" \
|
||||
'{ref: $ref, branch: $branch}')"
|
||||
--arg pr_number "${PR_NUMBER}" \
|
||||
--arg variant "${VARIANT}" \
|
||||
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
|
||||
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch to cloud repo
|
||||
|
||||
39
.github/workflows/cloud-dispatch-cleanup.yaml
vendored
Normal file
39
.github/workflows/cloud-dispatch-cleanup.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
|
||||
# frontend PR with a preview label is closed or has its preview label
|
||||
# removed. The cloud repo handles the actual environment teardown.
|
||||
#
|
||||
# This is fire-and-forget — it does NOT wait for the cloud workflow to
|
||||
# complete. Status is visible in the cloud repo's Actions tab.
|
||||
|
||||
name: Cloud Frontend Preview Cleanup Dispatch
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Only dispatch when:
|
||||
# - PR closed AND had a preview label
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
((github.event.action == 'closed' &&
|
||||
(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'))) ||
|
||||
(github.event.action == 'unlabeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch to cloud repo
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
|
||||
repository: Comfy-Org/cloud
|
||||
event-type: frontend-preview-cleanup
|
||||
client-payload: >-
|
||||
{"pr_number": "${{ github.event.pull_request.number }}"}
|
||||
@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
document.documentElement.classList.remove('dark-theme')
|
||||
document.body.classList.remove('dark-theme')
|
||||
}
|
||||
document.body.classList.add('[&_*]:!font-inter')
|
||||
document.body.classList.add('font-inter')
|
||||
|
||||
return Story(context.args, context)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
56
index.html
56
index.html
File diff suppressed because one or more lines are too long
@@ -20,6 +20,10 @@ const config: KnipConfig = {
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/shared-frontend-utils': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
@@ -32,7 +36,9 @@ const config: KnipConfig = {
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
'@primevue/icons',
|
||||
// Used by lucideStrokePlugin.js (CSS @plugin)
|
||||
'@iconify/utils'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
@@ -47,7 +53,9 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
51
public/splash.css
Normal file
51
public/splash.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Pre-Vue splash loader — colors set by inline script */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
19
src/App.vue
19
src/App.vue
@@ -2,20 +2,13 @@
|
||||
<router-view />
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -31,6 +24,16 @@ app.extensionManager = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
|
||||
watch(
|
||||
isLoading,
|
||||
(loading, prevLoading) => {
|
||||
if (prevLoading && !loading) {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
const { target } = event
|
||||
switch (true) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -54,6 +55,7 @@ export function useAppSetDefaultView() {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
|
||||
@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
160
src/components/common/ScrubableNumberInput.stories.ts
Normal file
160
src/components/common/ScrubableNumberInput.stories.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref, toRefs } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
|
||||
import ScrubableNumberInput from './ScrubableNumberInput.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof ScrubableNumberInput>
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Input/Number',
|
||||
component: ScrubableNumberInput,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
disabled: { control: 'boolean' },
|
||||
hideButtons: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
hideButtons: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-60"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { min, max, step, disabled, hideButtons } = toRefs(args)
|
||||
const value = ref(42)
|
||||
return { value, min, max, step, disabled, hideButtons }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min :max :step :disabled :hideButtons />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { disabled } = toRefs(args)
|
||||
const value = ref(50)
|
||||
return { value, disabled }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :disabled />'
|
||||
})
|
||||
}
|
||||
|
||||
export const AtMinimum: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(0)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const AtMaximum: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(100)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const FloatPrecision: Story = {
|
||||
args: { min: 0, max: 1, step: 0.01 },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { min, max, step } = toRefs(args)
|
||||
const value = ref(0.75)
|
||||
return { value, min, max, step }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min :max :step display-value="0.75" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeNumber: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(1809000312992)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const HiddenButtons: Story = {
|
||||
args: { hideButtons: true },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { hideButtons } = toRefs(args)
|
||||
const value = ref(42)
|
||||
return { value, hideButtons }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :hideButtons />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithControlButton: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput, Button, Popover },
|
||||
setup() {
|
||||
const value = ref(1809000312992)
|
||||
return { value }
|
||||
},
|
||||
template: `
|
||||
<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
|
||||
>
|
||||
<i class="icon-[lucide--shuffle] w-full text-xs text-primary-background" />
|
||||
</Button>
|
||||
</template>
|
||||
<div class="p-4 text-sm">Control popover content</div>
|
||||
</Popover>
|
||||
</ScrubableNumberInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -33,19 +33,20 @@
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
@keydown.up.prevent="updateValueBy(step)"
|
||||
@keydown.down.prevent="updateValueBy(-step)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
||||
/>
|
||||
<div
|
||||
ref="swipeElement"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -65,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
min = -Number.MAX_VALUE,
|
||||
max = Number.MAX_VALUE,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const swipeElement = useTemplateRef('swipeElement')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
let dragDelta = 0
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
if (isSwiping.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
onSwipeEnd: () => (dragDelta = 0)
|
||||
})
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<Button variant="secondary" @click="copySystemInfo">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copySystemInfo') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
@@ -46,6 +52,8 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
@@ -55,6 +63,8 @@ const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const systemInfo = computed(() => ({
|
||||
...props.stats.system,
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
@@ -108,7 +118,7 @@ function isOutdated(column: ColumnDef): boolean {
|
||||
return !!installed && !!required && installed !== required
|
||||
}
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
function getDisplayValue(column: ColumnDef) {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
@@ -118,4 +128,33 @@ const getDisplayValue = (column: ColumnDef) => {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSystemInfoText(): string {
|
||||
const lines: string[] = ['## System Info']
|
||||
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevices.value) {
|
||||
lines.push('')
|
||||
lines.push('## Devices')
|
||||
for (const device of props.stats.devices) {
|
||||
lines.push(`- ${device.name} (${device.type})`)
|
||||
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
|
||||
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
|
||||
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
|
||||
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function copySystemInfo() {
|
||||
copyToClipboard(formatSystemInfoText())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -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' : '')
|
||||
|
||||
@@ -479,50 +479,53 @@ useEventListener(
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
try {
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
throw settingsError.value
|
||||
}
|
||||
throw settingsError.value
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => 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'
|
||||
@@ -129,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>
|
||||
@@ -197,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 */
|
||||
@@ -216,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,96 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import LogoCFillLoader from './LogoCFillLoader.vue'
|
||||
|
||||
const meta: Meta<typeof LogoCFillLoader> = {
|
||||
title: 'Components/Loader/LogoCFillLoader',
|
||||
component: LogoCFillLoader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg', 'xl']
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['yellow', 'blue', 'white', 'black']
|
||||
},
|
||||
bordered: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disableAnimation: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: { size: 'xl' }
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: { bordered: false }
|
||||
}
|
||||
|
||||
export const Static: Story = {
|
||||
args: { disableAnimation: true }
|
||||
}
|
||||
|
||||
export const BrandColors: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-12">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Yellow</span>
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Blue</span>
|
||||
<LogoCFillLoader size="lg" color="blue" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">White</span>
|
||||
<LogoCFillLoader size="lg" color="white" />
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded" style="background: white">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-600">Black</span>
|
||||
<LogoCFillLoader size="lg" color="black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-8">
|
||||
<LogoCFillLoader size="sm" color="yellow" />
|
||||
<LogoCFillLoader size="md" color="yellow" />
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
<LogoCFillLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<span role="status" :class="cn('inline-flex', colorClass)">
|
||||
<svg
|
||||
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
|
||||
:height="heightMap[size]"
|
||||
:viewBox="`0 0 ${VB_W} ${VB_H}`"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<mask :id="maskId">
|
||||
<path :d="C_PATH" fill="white" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
v-if="bordered"
|
||||
:d="C_PATH"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<g :mask="`url(#${maskId})`">
|
||||
<rect
|
||||
:class="disableAnimation ? undefined : 'c-fill-rect'"
|
||||
:x="-BLEED"
|
||||
:y="-BLEED"
|
||||
:width="VB_W + BLEED * 2"
|
||||
:height="VB_H + BLEED * 2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId, computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'black',
|
||||
bordered = true,
|
||||
disableAnimation = false
|
||||
} = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'yellow' | 'blue' | 'white' | 'black'
|
||||
bordered?: boolean
|
||||
disableAnimation?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskId = `c-mask-${useId()}`
|
||||
|
||||
const VB_W = 185
|
||||
const VB_H = 201
|
||||
const BLEED = 1
|
||||
|
||||
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
|
||||
// while the COMFY wordmark is wide (879×284), so larger heights are needed
|
||||
// for visually comparable perceived size.
|
||||
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
|
||||
const colorMap = {
|
||||
yellow: 'text-brand-yellow',
|
||||
blue: 'text-brand-blue',
|
||||
white: 'text-white',
|
||||
black: 'text-black'
|
||||
} as const
|
||||
|
||||
const colorClass = computed(() => colorMap[color])
|
||||
|
||||
const C_PATH =
|
||||
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.c-fill-rect {
|
||||
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes c-fill-up {
|
||||
0% {
|
||||
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(v-bind(BLEED) * -1px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-fill-rect {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
213
src/components/ui/search-input/SearchAutocomplete.vue
Normal file
213
src/components/ui/search-input/SearchAutocomplete.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
v-model="modelValue"
|
||||
v-model:open="isOpen"
|
||||
ignore-filter
|
||||
:disabled
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
searchInputVariants({ size }),
|
||||
disabled && 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
@click="focus"
|
||||
>
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
:class="cn('absolute', sizeConfig.clearPos)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click.stop="clearSearch"
|
||||
>
|
||||
<i :class="cn('icon-[lucide--x]', sizeConfig.icon)" />
|
||||
</Button>
|
||||
<i
|
||||
v-else-if="loading"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute icon-[lucide--loader-circle] animate-spin',
|
||||
sizeConfig.iconPos,
|
||||
sizeConfig.icon
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute',
|
||||
sizeConfig.iconPos,
|
||||
sizeConfig.icon,
|
||||
icon
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'size-full border-none bg-transparent outline-none',
|
||||
sizeConfig.inputPl,
|
||||
sizeConfig.inputText
|
||||
)
|
||||
"
|
||||
:placeholder="placeholderText"
|
||||
:auto-focus="autofocus"
|
||||
@compositionstart="isComposing = true"
|
||||
@compositionend="isComposing = false"
|
||||
@keydown.enter="onEnterKey"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SearchInputVariants } from './searchInput.variants'
|
||||
import {
|
||||
searchInputSizeConfig,
|
||||
searchInputVariants
|
||||
} from './searchInput.variants'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
icon = 'icon-[lucide--search]',
|
||||
autofocus = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
suggestions = [],
|
||||
optionLabel,
|
||||
optionKey,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
autofocus?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
size?: SearchInputVariants['size']
|
||||
suggestions?: T[]
|
||||
optionLabel?: keyof T & string
|
||||
optionKey?: keyof T & string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: T]
|
||||
}>()
|
||||
|
||||
const sizeConfig = computed(() => searchInputSizeConfig[size])
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const isComposing = ref(false)
|
||||
|
||||
function focus() {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
|
||||
)
|
||||
|
||||
function clearSearch() {
|
||||
modelValue.value = ''
|
||||
focus()
|
||||
}
|
||||
|
||||
function getItemProperty(item: T, key: keyof T & string): string {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return String(item[key])
|
||||
}
|
||||
return String(item)
|
||||
}
|
||||
|
||||
function suggestionLabel(item: T): string {
|
||||
if (optionLabel) return getItemProperty(item, optionLabel)
|
||||
return String(item)
|
||||
}
|
||||
|
||||
function suggestionKey(item: T, index: number): string {
|
||||
if (optionKey) return getItemProperty(item, optionKey)
|
||||
return `${suggestionLabel(item)}-${index}`
|
||||
}
|
||||
|
||||
function suggestionValue(item: T): string {
|
||||
return suggestionLabel(item)
|
||||
}
|
||||
|
||||
function onSelectSuggestion(item: T) {
|
||||
modelValue.value = suggestionLabel(item)
|
||||
isOpen.value = false
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
function onEnterKey(e: KeyboardEvent) {
|
||||
if (isComposing.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => suggestions,
|
||||
(items) => {
|
||||
isOpen.value = items.length > 0 && !!modelValue.value
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
:ignore-filter="true"
|
||||
:open="false"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxRoot :open="false" ignore-filter :disabled :class="className">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:label="String(badge)"
|
||||
severity="contrast"
|
||||
variant="circle"
|
||||
class="ml-auto"
|
||||
class="ml-auto min-h-5 min-w-5 px-1 text-base-background"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"reportIssueTooltip": "Submit the error report to Comfy Org",
|
||||
"reportSent": "Report Submitted",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"copySystemInfo": "Copy System Info",
|
||||
"copyAll": "Copy All",
|
||||
"openNewIssue": "Open New Issue",
|
||||
"showReport": "Show Report",
|
||||
@@ -339,7 +340,8 @@
|
||||
"conflicting": "Conflicting",
|
||||
"inWorkflowSection": "IN WORKFLOW",
|
||||
"allInWorkflow": "All in: {workflowName}",
|
||||
"missingNodes": "Missing Nodes"
|
||||
"missingNodes": "Missing Nodes",
|
||||
"unresolvedNodes": "Unresolved Nodes"
|
||||
},
|
||||
"infoPanelEmpty": "Click an item to see the info",
|
||||
"applyChanges": "Apply Changes",
|
||||
@@ -404,6 +406,10 @@
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All",
|
||||
"unresolvedNodes": {
|
||||
"title": "Unresolved Missing Nodes",
|
||||
"message": "The following nodes are not installed and could not be found in the registry."
|
||||
},
|
||||
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
|
||||
"packsSelected": "packs selected",
|
||||
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
|
||||
@@ -3181,6 +3187,7 @@
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
|
||||
import CloudTemplate from './CloudTemplate.vue'
|
||||
|
||||
onMounted(() => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
@@ -160,6 +161,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionTriggerSource,
|
||||
ExecutionErrorMetadata,
|
||||
@@ -362,6 +363,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ describe('PostHogTelemetryProvider', () => {
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
@@ -104,7 +105,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
@@ -346,6 +348,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -137,13 +137,29 @@ export interface WorkflowImportMetadata {
|
||||
/**
|
||||
* The source of the workflow open/import action
|
||||
*/
|
||||
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
|
||||
open_source?:
|
||||
| 'file_button'
|
||||
| 'file_drop'
|
||||
| 'template'
|
||||
| 'shared_url'
|
||||
| 'unknown'
|
||||
}
|
||||
|
||||
export interface EnterLinearMetadata {
|
||||
source?: string
|
||||
}
|
||||
|
||||
type ShareFlowStep =
|
||||
| 'dialog_opened'
|
||||
| 'save_prompted'
|
||||
| 'link_created'
|
||||
| 'link_copied'
|
||||
|
||||
export interface ShareFlowMetadata {
|
||||
step: ShareFlowStep
|
||||
source?: 'app_mode' | 'graph_mode'
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow open metadata
|
||||
*/
|
||||
@@ -362,6 +378,7 @@ export interface TelemetryProvider {
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
|
||||
// Page visibility events
|
||||
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
|
||||
@@ -447,7 +464,8 @@ export const TelemetryEvents = {
|
||||
// Workflow Management
|
||||
WORKFLOW_IMPORTED: 'app:workflow_imported',
|
||||
WORKFLOW_OPENED: 'app:workflow_opened',
|
||||
ENTER_LINEAR_MODE: 'app:toggle_linear_mode',
|
||||
ENTER_LINEAR_MODE: 'app:app_mode_opened',
|
||||
SHARE_FLOW: 'app:share_flow',
|
||||
|
||||
// Page Visibility
|
||||
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
|
||||
@@ -521,4 +539,5 @@ export type TelemetryEventProperties =
|
||||
| HelpCenterClosedMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -26,17 +26,24 @@ import { refAutoReset } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { url } = defineProps<{
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { isAppMode } = useAppMode()
|
||||
const copied = refAutoReset(false, 2000)
|
||||
|
||||
async function handleCopy() {
|
||||
await copyToClipboard(url)
|
||||
copied.value = true
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -167,7 +167,9 @@ import type {
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -182,6 +184,11 @@ const publishDialog = useComfyHubPublishDialog()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
function getShareSource() {
|
||||
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
|
||||
}
|
||||
|
||||
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
|
||||
type DialogMode = 'shareLink' | 'publishToHub'
|
||||
@@ -298,6 +305,10 @@ async function refreshDialogState() {
|
||||
|
||||
if (!workflow || workflow.isTemporary || workflow.isModified) {
|
||||
dialogState.value = 'unsaved'
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'save_prompted',
|
||||
source: getShareSource()
|
||||
})
|
||||
if (workflow) {
|
||||
workflowName.value = stripJsonExtension(workflow.filename)
|
||||
}
|
||||
@@ -379,6 +390,10 @@ const {
|
||||
)
|
||||
dialogState.value = 'shared'
|
||||
acknowledged.value = false
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_created',
|
||||
source: getShareSource()
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowStore } from '../../management/stores/workflowStore'
|
||||
@@ -13,6 +15,7 @@ export function useShareDialog() {
|
||||
const dialogStore = useDialogStore()
|
||||
const { pruneLinearData } = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
@@ -51,7 +54,15 @@ export function useShareDialog() {
|
||||
share()
|
||||
}
|
||||
|
||||
function getShareSource() {
|
||||
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
|
||||
}
|
||||
|
||||
function showShareDialog() {
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'dialog_opened',
|
||||
source: getShareSource()
|
||||
})
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ShareWorkflowDialogContent,
|
||||
|
||||
@@ -164,7 +164,8 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
{ nodes: [] },
|
||||
true,
|
||||
true,
|
||||
'Test Workflow'
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
@@ -360,7 +361,8 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect.anything(),
|
||||
true,
|
||||
true,
|
||||
'Open shared workflow'
|
||||
'Open shared workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,7 +138,9 @@ export function useSharedWorkflowUrlLoader() {
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
|
||||
try {
|
||||
await app.loadGraphData(payload.workflowJson, true, true, workflowName)
|
||||
await app.loadGraphData(payload.workflowJson, true, true, workflowName, {
|
||||
openSource: 'shared_url'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
@@ -121,6 +122,7 @@ export function useTemplateUrlLoader() {
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -81,18 +81,16 @@ describe('WorkspaceAuthGate', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud builds - unauthenticated user', () => {
|
||||
it('shows spinner while waiting for Firebase auth', () => {
|
||||
it('hides slot while waiting for Firebase auth', () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -100,7 +98,7 @@ describe('WorkspaceAuthGate', () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
|
||||
mockIsInitialized.value = true
|
||||
mockCurrentUser.value = null
|
||||
@@ -179,8 +177,8 @@ describe('WorkspaceAuthGate', () => {
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
// Still showing spinner before timeout
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
// Slot not yet rendered before timeout
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
|
||||
// Advance past the 10 second timeout
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<slot v-if="isReady" />
|
||||
<div
|
||||
v-else
|
||||
class="fixed inset-0 z-1100 flex items-center justify-center bg-(--p-mask-background)"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" disable-animation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -20,6 +14,9 @@
|
||||
*
|
||||
* This prevents race conditions where API calls use Firebase tokens
|
||||
* instead of workspace tokens when the workspace feature is enabled.
|
||||
*
|
||||
* The splash loader in index.html (z-9999) covers the screen during this
|
||||
* phase, so no separate loading indicator is needed here.
|
||||
*/
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -30,7 +27,6 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
|
||||
const FIREBASE_INIT_TIMEOUT_MS = 16_000
|
||||
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
|
||||
|
||||
@@ -120,7 +120,7 @@ function getDropIndicator(node: LGraphNode) {
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl: buildImageUrl(),
|
||||
label: t('linearMode.dragAndDropImage'),
|
||||
label: props.mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
@@ -206,14 +206,14 @@ defineExpose({ runButtonClick })
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
:on-drag-drop="nodeData.onDragDrop"
|
||||
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
|
||||
:drop-indicator="nodeData.dropIndicator"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 **:[.h-7]:h-10',
|
||||
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
nodeData.hasErrors &&
|
||||
'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
@@ -325,4 +325,9 @@ defineExpose({ runButtonClick })
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mobile"
|
||||
class="flex size-full items-center bg-base-background p-4 text-center"
|
||||
v-text="t('linearMode.mobileNoWorkflow')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,6 @@ import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -76,13 +75,16 @@ function onClick(index: number) {
|
||||
}
|
||||
|
||||
const workflowsEntries = computed(() => {
|
||||
return workflowStore.openWorkflows.map((w) => ({
|
||||
label: w.filename,
|
||||
icon: w.activeState?.extra?.linearMode
|
||||
? 'icon-[lucide--panels-top-left] bg-primary-background'
|
||||
: undefined,
|
||||
command: () => workflowService.openWorkflow(w)
|
||||
}))
|
||||
return [
|
||||
...workflowStore.openWorkflows.map((w) => ({
|
||||
label: w.filename,
|
||||
icon: w.activeState?.extra?.linearMode
|
||||
? 'icon-[lucide--panels-top-left] bg-primary-background'
|
||||
: undefined,
|
||||
command: () => workflowService.openWorkflow(w),
|
||||
checked: workflowStore.activeWorkflow === w
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
const menuEntries = computed<MenuItem[]>(() => [
|
||||
@@ -157,9 +159,9 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
class="flex h-16 w-full items-center gap-3 border-b border-border-subtle bg-base-background px-4 py-3"
|
||||
>
|
||||
<DropdownMenu :entries="menuEntries" />
|
||||
<Popover
|
||||
<DropdownMenu
|
||||
:entries="workflowsEntries"
|
||||
class="w-(--reka-popover-content-available-width)"
|
||||
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width)"
|
||||
:collision-padding="20"
|
||||
>
|
||||
<template #button>
|
||||
@@ -179,7 +181,7 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</DropdownMenu>
|
||||
<CurrentUserButton v-if="isLoggedIn" :show-arrow="false" />
|
||||
</header>
|
||||
<div class="size-full rounded-b-4xl contain-content">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { ImageCompareValue } from './WidgetImageCompare.vue'
|
||||
import WidgetImageCompare from './WidgetImageCompare.vue'
|
||||
|
||||
function createSampleImage(label: string, fill: string): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">` +
|
||||
`<rect width="512" height="512" fill="${fill}" />` +
|
||||
`<text x="50%" y="50%" fill="white" font-size="40"` +
|
||||
` text-anchor="middle" dominant-baseline="middle">` +
|
||||
`${label}</text></svg>`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
const SAMPLE_BEFORE = createSampleImage('Before', '#475569')
|
||||
const SAMPLE_AFTER = createSampleImage('After', '#0f766e')
|
||||
|
||||
const meta: Meta<typeof WidgetImageCompare> = {
|
||||
title: 'Components/Display/ImageCompare',
|
||||
component: WidgetImageCompare,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-88 h-80"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetImageCompare },
|
||||
setup() {
|
||||
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
|
||||
name: 'compare',
|
||||
type: 'IMAGE_COMPARE',
|
||||
value: {
|
||||
beforeImages: [SAMPLE_BEFORE],
|
||||
afterImages: [SAMPLE_AFTER]
|
||||
}
|
||||
})
|
||||
return { widget }
|
||||
},
|
||||
template: '<WidgetImageCompare :widget="widget" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBatchNavigation: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetImageCompare },
|
||||
setup() {
|
||||
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
|
||||
name: 'compare',
|
||||
type: 'IMAGE_COMPARE',
|
||||
value: {
|
||||
beforeImages: [SAMPLE_BEFORE, SAMPLE_AFTER],
|
||||
afterImages: [SAMPLE_AFTER, SAMPLE_BEFORE],
|
||||
beforeAlt: 'Before batch',
|
||||
afterAlt: 'After batch'
|
||||
}
|
||||
})
|
||||
return { widget }
|
||||
},
|
||||
template: '<WidgetImageCompare :widget="widget" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const SingleImageFallback: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetImageCompare },
|
||||
setup() {
|
||||
const widget = ref<SimplifiedWidget<string>>({
|
||||
name: 'compare',
|
||||
type: 'IMAGE_COMPARE',
|
||||
value: SAMPLE_BEFORE
|
||||
})
|
||||
return { widget }
|
||||
},
|
||||
template: '<WidgetImageCompare :widget="widget" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const NoImages: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetImageCompare },
|
||||
setup() {
|
||||
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
|
||||
name: 'compare',
|
||||
type: 'IMAGE_COMPARE',
|
||||
value: {}
|
||||
})
|
||||
return { widget }
|
||||
},
|
||||
template: '<WidgetImageCompare :widget="widget" />'
|
||||
})
|
||||
}
|
||||
@@ -58,7 +58,7 @@ describe('WidgetImageCompare Display', () => {
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
|
||||
images.forEach((img) => {
|
||||
expect(img.classes()).toContain('object-contain')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -290,7 +290,6 @@ describe('WidgetImageCompare Display', () => {
|
||||
|
||||
const slider = wrapper.find('[role="presentation"]')
|
||||
expect(slider.exists()).toBe(true)
|
||||
expect(slider.classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('does not render slider when no images', () => {
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
<div
|
||||
v-if="beforeImage || afterImage"
|
||||
ref="containerRef"
|
||||
class="relative min-h-0 flex-1"
|
||||
class="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-node-component-surface py-4"
|
||||
>
|
||||
<img
|
||||
v-if="afterImage"
|
||||
:src="afterImage"
|
||||
:alt="afterAlt"
|
||||
draggable="false"
|
||||
class="size-full object-contain"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
|
||||
<img
|
||||
@@ -41,12 +41,18 @@
|
||||
:src="beforeImage"
|
||||
:alt="beforeAlt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-contain"
|
||||
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
:style="
|
||||
hasCompareImages
|
||||
? { clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Circular drag handle -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
|
||||
v-if="hasCompareImages"
|
||||
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
role="presentation"
|
||||
/>
|
||||
@@ -142,6 +148,10 @@ const afterImage = computed(() => {
|
||||
return value?.afterImages?.[afterIndex.value] ?? ''
|
||||
})
|
||||
|
||||
const hasCompareImages = computed(() =>
|
||||
Boolean(beforeImage.value && afterImage.value)
|
||||
)
|
||||
|
||||
const beforeAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return !isSingleImage(value) && value?.beforeAlt
|
||||
|
||||
@@ -121,12 +121,6 @@ const buttonsDisabled = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
||||
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
|
||||
const buttonTooltip = computed(() => {
|
||||
if (buttonsDisabled.value) {
|
||||
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
|
||||
@@ -171,10 +165,6 @@ const inputAriaAttrs = computed(() => ({
|
||||
:parse-value="parseWidgetValue"
|
||||
:input-attrs="inputAriaAttrs"
|
||||
:class="cn(WidgetInputBaseClass, 'relative flex h-7 grow text-xs')"
|
||||
@keydown.up.prevent="updateValueBy(stepValue)"
|
||||
@keydown.down.prevent="updateValueBy(-stepValue)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
|
||||
>
|
||||
<template #background>
|
||||
<div
|
||||
|
||||
@@ -17,7 +17,7 @@ interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetInputText> {
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/WidgetInputText',
|
||||
title: 'Components/Input/InputText',
|
||||
component: WidgetInputText,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
|
||||
@@ -16,7 +16,7 @@ interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetTextarea> {
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/WidgetTextarea',
|
||||
title: 'Components/Input/TextArea',
|
||||
component: WidgetTextarea,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
|
||||
@@ -1830,7 +1830,6 @@ export class ComfyApp {
|
||||
)
|
||||
if (missingNodeTypes.length) {
|
||||
this.showMissingNodesError(missingNodeTypes.map((t) => t.class_type))
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(apiData)
|
||||
|
||||
@@ -101,7 +101,7 @@ export const useColorPaletteService = () => {
|
||||
linkColorPalette: Colors['node_slot']
|
||||
) {
|
||||
if (!linkColorPalette) return
|
||||
const rootStyle = document.body?.style
|
||||
const rootStyle = document.documentElement?.style
|
||||
if (!rootStyle) return
|
||||
|
||||
for (const dataType of nodeDefStore.nodeDataTypes) {
|
||||
@@ -121,7 +121,7 @@ export const useColorPaletteService = () => {
|
||||
colorPaletteId: string
|
||||
) {
|
||||
if (!palette) return
|
||||
const rootStyle = document.body?.style
|
||||
const rootStyle = document.documentElement?.style
|
||||
if (!rootStyle) return
|
||||
|
||||
for (const themeVar of Object.keys(THEME_PROPERTY_MAP)) {
|
||||
@@ -206,7 +206,10 @@ export const useColorPaletteService = () => {
|
||||
*
|
||||
* @param comfyColorPalette - The palette to set.
|
||||
*/
|
||||
const loadComfyColorPalette = (comfyColorPalette: Colors['comfy_base']) => {
|
||||
const loadComfyColorPalette = (
|
||||
comfyColorPalette: Colors['comfy_base'],
|
||||
isLightTheme: boolean
|
||||
) => {
|
||||
if (!comfyColorPalette) return
|
||||
const rootStyle = document.documentElement.style
|
||||
for (const [key, value] of Object.entries(comfyColorPalette)) {
|
||||
@@ -228,6 +231,14 @@ export const useColorPaletteService = () => {
|
||||
} else {
|
||||
rootStyle.removeProperty('--bg-img')
|
||||
}
|
||||
|
||||
try {
|
||||
const splashBg = isLightTheme ? '#FFFFFF' : comfyColorPalette['bg-color']
|
||||
localStorage.setItem('comfy-splash-bg', splashBg)
|
||||
localStorage.setItem('comfy-splash-fg', comfyColorPalette['fg-color'])
|
||||
} catch (_) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,7 +260,10 @@ export const useColorPaletteService = () => {
|
||||
colorPaletteId
|
||||
)
|
||||
loadLinkColorPaletteForVueNodes(completedPalette.colors.node_slot)
|
||||
loadComfyColorPalette(completedPalette.colors.comfy_base)
|
||||
loadComfyColorPalette(
|
||||
completedPalette.colors.comfy_base,
|
||||
completedPalette.light_theme === true
|
||||
)
|
||||
app.canvas.setDirty(true, true)
|
||||
|
||||
colorPaletteStore.activePaletteId = colorPaletteId
|
||||
|
||||
@@ -71,46 +71,15 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock TaskItemImpl
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
TaskItemImpl: class {
|
||||
public flatOutputs: Array<{
|
||||
supportsPreview: boolean
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
url: string
|
||||
}>
|
||||
public previewOutput:
|
||||
| {
|
||||
supportsPreview: boolean
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
| undefined
|
||||
public jobId: string
|
||||
|
||||
constructor(public job: JobListItem) {
|
||||
this.jobId = job.id
|
||||
this.flatOutputs = [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/test.png'
|
||||
}
|
||||
]
|
||||
this.previewOutput = this.flatOutputs[0]
|
||||
}
|
||||
|
||||
get previewableOutputs() {
|
||||
return this.flatOutputs.filter((o) => o.supportsPreview)
|
||||
}
|
||||
// Use the real TaskItemImpl and ResultItemImpl from queueStore
|
||||
// to avoid redefining types and filtering logic inline.
|
||||
vi.mock('@/stores/queueStore', async (importOriginal) => {
|
||||
const original = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
TaskItemImpl: original.TaskItemImpl,
|
||||
ResultItemImpl: original.ResultItemImpl
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock asset mappers - add unique timestamps
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
@@ -140,20 +109,29 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
let store: ReturnType<typeof useAssetsStore>
|
||||
|
||||
// Helper function to create mock job items
|
||||
const createMockJobItem = (index: number): JobListItem => ({
|
||||
const createMockJobItem = (
|
||||
index: number,
|
||||
overrides?: {
|
||||
status?: JobListItem['status']
|
||||
preview_output?: JobListItem['preview_output'] | null
|
||||
}
|
||||
): JobListItem => ({
|
||||
id: `prompt_${index}`,
|
||||
status: 'completed',
|
||||
status: overrides?.status ?? 'completed',
|
||||
create_time: 1000 + index,
|
||||
update_time: 1000 + index,
|
||||
last_state_update: 1000 + index,
|
||||
priority: 1000 + index,
|
||||
preview_output: {
|
||||
filename: `output_${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
preview_output:
|
||||
overrides?.preview_output === null
|
||||
? undefined
|
||||
: (overrides?.preview_output ?? {
|
||||
filename: `output_${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'images'
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -494,6 +472,140 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
expect(Array.isArray(asset.user_metadata!.allOutputs)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media Type Filtering', () => {
|
||||
it('should include image outputs as previewable', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, {
|
||||
preview_output: {
|
||||
filename: 'photo.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should include video outputs as previewable', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, {
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'video'
|
||||
}
|
||||
})
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should include audio outputs as previewable', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, {
|
||||
preview_output: {
|
||||
filename: 'sound.mp3',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should include 3D outputs as previewable', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, {
|
||||
preview_output: {
|
||||
filename: 'model.glb',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: '3d'
|
||||
}
|
||||
})
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should skip non-previewable outputs (e.g., text files)', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, {
|
||||
preview_output: {
|
||||
filename: 'data.txt',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'text'
|
||||
}
|
||||
})
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should skip jobs without preview_output', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, { preview_output: null }),
|
||||
createMockJobItem(1)
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should skip non-completed jobs', async () => {
|
||||
const mockHistory = [
|
||||
createMockJobItem(0, { status: 'failed' }),
|
||||
createMockJobItem(1, { status: 'cancelled' }),
|
||||
createMockJobItem(2, { status: 'pending' }),
|
||||
createMockJobItem(3)
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle empty history response', async () => {
|
||||
vi.mocked(api.getHistory).mockResolvedValue([])
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(0)
|
||||
expect(store.hasMoreHistory).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
|
||||
@@ -132,7 +132,6 @@ watch(
|
||||
} else {
|
||||
document.body.classList.add(DARK_THEME_CLASS)
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
electronAPI().changeTheme({
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
|
||||
@@ -87,6 +87,8 @@ const login = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
|
||||
if (!userStore.initialized) {
|
||||
await userStore.initialize()
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -15,50 +15,22 @@
|
||||
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<SingleSelect
|
||||
v-model="searchMode"
|
||||
class="min-w-34"
|
||||
:options="filterOptions"
|
||||
/>
|
||||
<AutoCompletePlus
|
||||
v-model.lazy="searchQuery"
|
||||
<SearchAutocomplete
|
||||
v-model="searchQuery"
|
||||
:suggestions="suggestions"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full max-w-lg min-w-md"
|
||||
:pt="{
|
||||
root: { class: 'relative' },
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class:
|
||||
'w-full h-10 rounded-lg bg-comfy-input text-comfy-input-foreground border-none outline-none text-sm'
|
||||
}
|
||||
},
|
||||
overlay: {
|
||||
class:
|
||||
'bg-comfy-input rounded-lg mt-1 shadow-lg border border-border-default'
|
||||
},
|
||||
list: { class: 'p-1' },
|
||||
option: {
|
||||
class:
|
||||
'px-3 py-2 rounded hover:bg-button-hover-surface cursor-pointer text-sm'
|
||||
},
|
||||
loader: { style: 'display: none' }
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
>
|
||||
<template #dropdownicon>
|
||||
<i
|
||||
class="pi pi-search absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
</AutoCompletePlus>
|
||||
autofocus
|
||||
size="lg"
|
||||
class="max-w-96 flex-1"
|
||||
@select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
@@ -128,6 +100,10 @@
|
||||
<div v-if="isLoading" class="size-full scrollbar-hide overflow-auto">
|
||||
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
||||
</div>
|
||||
<UnresolvedNodesMessage
|
||||
v-else-if="isUnresolvedTab"
|
||||
:node-names="unresolvedNodeNames"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
v-else-if="displayPacks.length === 0"
|
||||
:title="emptyStateTitle"
|
||||
@@ -166,8 +142,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { until, whenever } from '@vueuse/core'
|
||||
import { merge, stubTrue } from 'es-toolkit/compat'
|
||||
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
@@ -183,7 +158,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
|
||||
import SearchAutocomplete from '@/components/ui/search-input/SearchAutocomplete.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'
|
||||
@@ -192,6 +167,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import type { QuerySuggestion } from '@/types/searchServiceTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
|
||||
@@ -199,6 +175,7 @@ import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPan
|
||||
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
|
||||
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
|
||||
import UnresolvedNodesMessage from '@/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
@@ -246,6 +223,7 @@ const {
|
||||
// Missing nodes composable
|
||||
const {
|
||||
missingNodePacks,
|
||||
unresolvedNodeNames,
|
||||
isLoading: isMissingLoading,
|
||||
error: missingError
|
||||
} = useMissingNodes()
|
||||
@@ -309,7 +287,17 @@ const navItems = computed<(NavItemData | NavGroupData)[]>(() => [
|
||||
id: ManagerTab.Missing,
|
||||
label: t('manager.nav.missingNodes'),
|
||||
icon: 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
},
|
||||
...(unresolvedNodeNames.value.length > 0
|
||||
? [
|
||||
{
|
||||
id: ManagerTab.Unresolved,
|
||||
label: t('manager.nav.unresolvedNodes'),
|
||||
icon: 'icon-[lucide--help-circle]',
|
||||
badge: unresolvedNodeNames.value.length
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -337,6 +325,12 @@ const selectedTab = computed(() =>
|
||||
findNavItemById(navItems.value, selectedNavId.value)
|
||||
)
|
||||
|
||||
watch(navItems, (items) => {
|
||||
if (selectedNavId.value && !findNavItemById(items, selectedNavId.value)) {
|
||||
selectedNavId.value = ManagerTab.Missing
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
pageNumber,
|
||||
@@ -377,8 +371,8 @@ const availableSortOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
||||
searchQuery.value = event.value.query
|
||||
const onOptionSelect = (suggestion: QuerySuggestion) => {
|
||||
searchQuery.value = suggestion.query
|
||||
}
|
||||
|
||||
const onApproachEnd = () => {
|
||||
@@ -403,6 +397,9 @@ const isUpdateAvailableTab = computed(
|
||||
const isMissingTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Missing
|
||||
)
|
||||
const isUnresolvedTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Unresolved
|
||||
)
|
||||
|
||||
// Map of tab IDs to their empty state i18n key suffixes
|
||||
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-3 p-8">
|
||||
<i class="icon-[lucide--triangle-alert] text-4xl text-warning-background" />
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ $t('manager.unresolvedNodes.title') }}
|
||||
</h3>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('manager.unresolvedNodes.message') }}
|
||||
</p>
|
||||
<ul class="mt-2 flex flex-col gap-1 rounded-lg bg-secondary-background p-2">
|
||||
<li
|
||||
v-for="name in nodeNames"
|
||||
:key="name"
|
||||
class="px-3 py-1.5 font-mono text-sm"
|
||||
>
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
nodeNames: string[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -94,6 +94,7 @@ describe('useMissingNodes', () => {
|
||||
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -119,6 +120,7 @@ describe('useMissingNodes', () => {
|
||||
it('filters out installed packs correctly', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -140,6 +142,7 @@ describe('useMissingNodes', () => {
|
||||
it('returns empty array when all packs are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -158,6 +161,7 @@ describe('useMissingNodes', () => {
|
||||
it('returns all packs when none are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -191,6 +195,7 @@ describe('useMissingNodes', () => {
|
||||
it('fetches even when packs already exist (watch always fires with immediate:true)', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -206,6 +211,7 @@ describe('useMissingNodes', () => {
|
||||
it('fetches even when already loading (watch fires regardless of loading state)', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -223,6 +229,7 @@ describe('useMissingNodes', () => {
|
||||
it('exposes loading state from useWorkflowPacks', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -239,6 +246,7 @@ describe('useMissingNodes', () => {
|
||||
const testError = 'Failed to fetch workflow packs'
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(testError),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -257,6 +265,7 @@ describe('useMissingNodes', () => {
|
||||
const workflowPacksRef = ref<WorkflowPack[]>([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -283,6 +292,7 @@ describe('useMissingNodes', () => {
|
||||
const workflowPacksRef = ref(mockWorkflowPacks)
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -306,6 +316,68 @@ describe('useMissingNodes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('unresolved nodes', () => {
|
||||
it('reports hasMissingNodes when unresolvedNodeNames is non-empty', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref(['UnknownNode1', 'UnknownNode2']),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(true)
|
||||
expect(unresolvedNodeNames.value).toEqual([
|
||||
'UnknownNode1',
|
||||
'UnknownNode2'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report hasMissingNodes when unresolvedNodeNames is empty', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates reactively when unresolvedNodeNames changes', async () => {
|
||||
const unresolvedRef = ref<string[]>([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: unresolvedRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(false)
|
||||
expect(unresolvedNodeNames.value).toEqual([])
|
||||
|
||||
unresolvedRef.value = ['NewMissingNode']
|
||||
await nextTick()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(true)
|
||||
expect(unresolvedNodeNames.value).toEqual(['NewMissingNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
const createMockNode = (type: string, packId?: string, version?: string) =>
|
||||
createMockLGraphNode({
|
||||
|
||||
@@ -21,8 +21,13 @@ export const useMissingNodes = createSharedComposable(() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||
useWorkflowPacks()
|
||||
const {
|
||||
workflowPacks,
|
||||
unresolvedNodeNames,
|
||||
isLoading,
|
||||
error,
|
||||
startFetchWorkflowPacks
|
||||
} = useWorkflowPacks()
|
||||
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
@@ -67,7 +72,8 @@ export const useMissingNodes = createSharedComposable(() => {
|
||||
const hasMissingNodes = computed(() => {
|
||||
return (
|
||||
missingNodePacks.value.length > 0 ||
|
||||
Object.keys(missingCoreNodes.value).length > 0
|
||||
Object.keys(missingCoreNodes.value).length > 0 ||
|
||||
unresolvedNodeNames.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
@@ -83,6 +89,7 @@ export const useMissingNodes = createSharedComposable(() => {
|
||||
return {
|
||||
missingNodePacks,
|
||||
missingCoreNodes,
|
||||
unresolvedNodeNames,
|
||||
hasMissingNodes,
|
||||
isLoading,
|
||||
error
|
||||
|
||||
@@ -31,6 +31,7 @@ const _useWorkflowPacks = () => {
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
|
||||
const workflowPacks = ref<WorkflowPack[]>([])
|
||||
const unresolvedNodeNames = ref<string[]>([])
|
||||
|
||||
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
|
||||
if (typeof node.properties?.cnr_id === 'string') {
|
||||
@@ -111,12 +112,39 @@ const _useWorkflowPacks = () => {
|
||||
|
||||
/**
|
||||
* Get the node packs for all nodes in the workflow (including subgraphs).
|
||||
* Nodes that have no local definition and no registry match are tracked
|
||||
* as unresolved so downstream consumers can surface them to the user.
|
||||
*/
|
||||
const getWorkflowPacks = async () => {
|
||||
if (!app.rootGraph) return []
|
||||
const packPromises = mapAllNodes(app.rootGraph, workflowNodeToPack)
|
||||
const packs = await Promise.all(packPromises)
|
||||
workflowPacks.value = packs.filter((pack) => pack !== undefined)
|
||||
if (!app.rootGraph) {
|
||||
workflowPacks.value = []
|
||||
unresolvedNodeNames.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPacks: WorkflowPack[] = []
|
||||
const unresolved: string[] = []
|
||||
|
||||
await Promise.all(
|
||||
mapAllNodes(app.rootGraph, async (node) => {
|
||||
const pack = await workflowNodeToPack(node)
|
||||
if (pack) {
|
||||
resolvedPacks.push(pack)
|
||||
} else {
|
||||
const nodeName = node.type
|
||||
if (
|
||||
nodeName &&
|
||||
getWorkflowNodePackId(node) === undefined &&
|
||||
!nodeDefStore.nodeDefsByName[nodeName]
|
||||
) {
|
||||
unresolved.push(nodeName)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
workflowPacks.value = resolvedPacks
|
||||
unresolvedNodeNames.value = [...new Set(unresolved)]
|
||||
}
|
||||
|
||||
const packsToUniqueIds = (packs: WorkflowPack[]) =>
|
||||
@@ -147,6 +175,7 @@ const _useWorkflowPacks = () => {
|
||||
isLoading,
|
||||
isReady,
|
||||
workflowPacks: nodePacks,
|
||||
unresolvedNodeNames,
|
||||
startFetchWorkflowPacks: async () => {
|
||||
await getWorkflowPacks() // Parse the packs from the workflow nodes
|
||||
await startFetch() // Fetch the packs infos from the registry
|
||||
|
||||
@@ -176,6 +176,9 @@ export function useManagerDisplayPacks(
|
||||
return sortPacks(filterNotInstalled(base))
|
||||
}
|
||||
|
||||
case ManagerTab.Unresolved:
|
||||
return []
|
||||
|
||||
default:
|
||||
return searchResults.value
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export enum ManagerTab {
|
||||
UpdateAvailable = 'updateAvailable',
|
||||
Conflicting = 'conflicting',
|
||||
Workflow = 'workflow',
|
||||
Missing = 'missing'
|
||||
Missing = 'missing',
|
||||
Unresolved = 'unresolved'
|
||||
}
|
||||
|
||||
export type TaskLog = {
|
||||
|
||||
Reference in New Issue
Block a user