mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-28 10:44:12 +00:00
fix: merge main into webcam-capture branch
This commit is contained in:
26
src/AGENTS.md
Normal file
26
src/AGENTS.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Source Code Guidelines
|
||||
|
||||
## Error Handling
|
||||
|
||||
- User-friendly and actionable messages
|
||||
- Proper error propagation
|
||||
|
||||
## Security
|
||||
|
||||
- Sanitize HTML with DOMPurify
|
||||
- Validate trusted sources
|
||||
- Never log secrets
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
- Follow domain-driven design for organizing files/folders
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
- Clean up subscriptions
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use `es-toolkit` for utility functions
|
||||
- Use TypeScript for type safety
|
||||
- Avoid `@ts-expect-error` - fix the underlying issue
|
||||
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)
|
||||
31
src/App.vue
31
src/App.vue
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
@@ -16,10 +17,6 @@ import { computed, onMounted } from 'vue'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { t } from '@/i18n'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
@@ -27,8 +24,6 @@ import { electronAPI, isElectron } from './utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
workspaceStore.shiftDown = e.shiftKey
|
||||
@@ -54,23 +49,15 @@ onMounted(() => {
|
||||
document.addEventListener('contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
|
||||
window.addEventListener('vite:preloadError', async (_event) => {
|
||||
// Auto-reload if app is not ready or there are no unsaved changes
|
||||
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
|
||||
window.location.reload()
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
})
|
||||
} else {
|
||||
// Show confirmation dialog if there are unsaved changes
|
||||
await dialogService
|
||||
.confirm({
|
||||
title: t('g.vitePreloadErrorTitle'),
|
||||
message: t('g.vitePreloadErrorMessage')
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (confirmed) {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
console.error('[vite:preloadError]', event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,57 +1,3 @@
|
||||
# Source Code Guidelines
|
||||
|
||||
## Service Layer
|
||||
|
||||
### API Calls
|
||||
|
||||
- Use `api.apiURL()` for backend endpoints
|
||||
- Use `api.fileURL()` for static files
|
||||
|
||||
#### ✅ Correct Usage
|
||||
```typescript
|
||||
// Backend API call
|
||||
const response = await api.get(api.apiURL('/prompt'))
|
||||
|
||||
// Static file
|
||||
const template = await fetch(api.fileURL('/templates/default.json'))
|
||||
```
|
||||
|
||||
#### ❌ Incorrect Usage
|
||||
```typescript
|
||||
// WRONG - Direct URL construction
|
||||
const response = await fetch('/api/prompt')
|
||||
const template = await fetch('/templates/default.json')
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- User-friendly and actionable messages
|
||||
- Proper error propagation
|
||||
|
||||
### Security
|
||||
|
||||
- Sanitize HTML with DOMPurify
|
||||
- Validate trusted sources
|
||||
- Never log secrets
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
### Store Design
|
||||
|
||||
- Follow domain-driven design
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use TypeScript for type safety
|
||||
- Implement proper error handling
|
||||
- Clean up subscriptions
|
||||
- Avoid @ts-expect-error
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use es-toolkit for utility functions
|
||||
- Implement proper TypeScript types
|
||||
- Follow Vue 3 composition API style guide
|
||||
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
|
||||
<!-- We forked the path, yet here we are again—
|
||||
Maintaining two files where one would have been sane. -->
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* List transition animations */
|
||||
.list-scale-move,
|
||||
.list-scale-enter-active,
|
||||
.list-scale-leave-active {
|
||||
transition: opacity 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.list-scale-enter-from,
|
||||
.list-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(70%);
|
||||
}
|
||||
|
||||
.list-scale-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface IdleDeadline {
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
type GlobalWindow = typeof globalThis
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
@@ -21,7 +22,7 @@ interface IDisposable {
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
targetWindow: GlobalWindow,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
|
||||
199
src/base/common/downloadUtil.test.ts
Normal file
199
src/base/common/downloadUtil.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud
|
||||
}
|
||||
}))
|
||||
|
||||
// Global stubs
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = vi
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
describe('downloadUtil', () => {
|
||||
let mockLink: HTMLAnchorElement
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud = false
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
revokeObjectURLSpy.mockClear().mockImplementation(() => {})
|
||||
// Create a mock anchor element
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
style: { display: '' }
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
// Spy on DOM methods
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink)
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should create and trigger download with basic URL', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith('a')
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe('download.png') // Default filename
|
||||
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use custom filename when provided', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
const customFilename = 'my-custom-image.png'
|
||||
|
||||
downloadFile(testUrl, customFilename)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe(customFilename)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should extract filename from URL query parameters', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg&other=param'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe('extracted-image.jpg')
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use default filename when URL has no filename parameter', () => {
|
||||
const testUrl = 'https://example.com/api/file?other=param'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(mockLink.href).toBe(testUrl)
|
||||
expect(mockLink.download).toBe('download.png')
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle invalid URLs gracefully', () => {
|
||||
const invalidUrl = 'not-a-valid-url'
|
||||
|
||||
downloadFile(invalidUrl)
|
||||
|
||||
expect(mockLink.href).toBe(invalidUrl)
|
||||
expect(mockLink.download).toBe('download.png')
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer custom filename over extracted filename', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg'
|
||||
const customFilename = 'custom-override.png'
|
||||
|
||||
downloadFile(testUrl, customFilename)
|
||||
|
||||
expect(mockLink.download).toBe(customFilename)
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle URLs with empty filename parameter', () => {
|
||||
const testUrl = 'https://example.com/api/file?filename='
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(mockLink.download).toBe('download.png')
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle relative URLs by using window.location.origin', () => {
|
||||
const relativeUrl = '/api/file?filename=relative-image.png'
|
||||
|
||||
downloadFile(relativeUrl)
|
||||
|
||||
expect(mockLink.href).toBe(relativeUrl)
|
||||
expect(mockLink.download).toBe('relative-image.png')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clean up DOM elements after download', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
// Verify the element was added and then removed
|
||||
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
|
||||
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('streams downloads via blob when running in cloud', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(blobFn).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs an error when cloud fetch fails', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
46
src/base/credits/comfyCredits.test.ts
Normal file
46
src/base/credits/comfyCredits.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
formatCreditsFromCents,
|
||||
formatCreditsFromUsd,
|
||||
formatUsd,
|
||||
formatUsdFromCents,
|
||||
usdToCents,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
|
||||
describe('comfyCredits helpers', () => {
|
||||
test('exposes the fixed conversion rate', () => {
|
||||
expect(CREDITS_PER_USD).toBe(211)
|
||||
expect(COMFY_CREDIT_RATE_CENTS).toBeCloseTo(2.11) // credits per cent
|
||||
})
|
||||
|
||||
test('converts between USD and cents', () => {
|
||||
expect(usdToCents(1.23)).toBe(123)
|
||||
expect(formatUsdFromCents({ cents: 123, locale: 'en-US' })).toBe('1.23')
|
||||
})
|
||||
|
||||
test('converts cents to credits and back', () => {
|
||||
expect(centsToCredits(100)).toBe(211) // 100 cents = 211 credits
|
||||
expect(creditsToCents(211)).toBe(100) // 211 credits = 100 cents
|
||||
})
|
||||
|
||||
test('converts USD to credits and back', () => {
|
||||
expect(usdToCredits(1)).toBe(211) // 1 USD = 211 credits
|
||||
expect(creditsToUsd(211)).toBe(1) // 211 credits = 1 USD
|
||||
})
|
||||
|
||||
test('formats credits and USD values using en-US locale', () => {
|
||||
const locale = 'en-US'
|
||||
expect(formatCredits({ value: 1234.567, locale })).toBe('1,234.57')
|
||||
expect(formatCreditsFromCents({ cents: 100, locale })).toBe('211.00')
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
})
|
||||
125
src/base/credits/comfyCredits.ts
Normal file
125
src/base/credits/comfyCredits.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
|
||||
const formatNumber = ({
|
||||
value,
|
||||
locale,
|
||||
options
|
||||
}: {
|
||||
value: number
|
||||
locale?: string
|
||||
options?: Intl.NumberFormatOptions
|
||||
}): string => {
|
||||
const merged: Intl.NumberFormatOptions = {
|
||||
...DEFAULT_NUMBER_FORMAT,
|
||||
...options
|
||||
}
|
||||
|
||||
if (
|
||||
typeof merged.maximumFractionDigits === 'number' &&
|
||||
typeof merged.minimumFractionDigits === 'number' &&
|
||||
merged.maximumFractionDigits < merged.minimumFractionDigits
|
||||
) {
|
||||
merged.minimumFractionDigits = merged.maximumFractionDigits
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, merged).format(value)
|
||||
}
|
||||
|
||||
export const CREDITS_PER_USD = 211
|
||||
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
|
||||
|
||||
export const usdToCents = (usd: number): number => Math.round(usd * 100)
|
||||
|
||||
export const centsToCredits = (cents: number): number =>
|
||||
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
|
||||
|
||||
export const creditsToCents = (credits: number): number =>
|
||||
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
|
||||
|
||||
export const usdToCredits = (usd: number): number =>
|
||||
Math.round(usd * CREDITS_PER_USD)
|
||||
|
||||
export const creditsToUsd = (credits: number): number =>
|
||||
Math.round((credits / CREDITS_PER_USD) * 100) / 100
|
||||
|
||||
export type FormatOptions = {
|
||||
value: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromCentsOptions = {
|
||||
cents: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromUsdOptions = {
|
||||
usd: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export const formatCredits = ({
|
||||
value,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatOptions): string =>
|
||||
formatNumber({ value, locale, options: numberOptions })
|
||||
|
||||
export const formatCreditsFromCents = ({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromCentsOptions): string =>
|
||||
formatCredits({
|
||||
value: centsToCredits(cents),
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
export const formatCreditsFromUsd = ({
|
||||
usd,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromUsdOptions): string =>
|
||||
formatCredits({
|
||||
value: usdToCredits(usd),
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
export const formatUsd = ({
|
||||
value,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatOptions): string =>
|
||||
formatNumber({
|
||||
value,
|
||||
locale,
|
||||
options: numberOptions
|
||||
})
|
||||
|
||||
export const formatUsdFromCents = ({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromCentsOptions): string =>
|
||||
formatUsd({
|
||||
value: cents / 100,
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
/**
|
||||
* Clamps a USD value to the allowed range for credit purchases
|
||||
* @param value - The USD amount to clamp
|
||||
* @returns The clamped value between $1 and $1000, or 0 if NaN
|
||||
*/
|
||||
export const clampUsd = (value: number): number => {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(1000, Math.max(1, value))
|
||||
}
|
||||
6
src/components/AGENTS.md
Normal file
6
src/components/AGENTS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Component Guidelines
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
@@ -1,45 +1,3 @@
|
||||
# Component Guidelines
|
||||
|
||||
## Vue 3 Composition API
|
||||
|
||||
- Use setup() function
|
||||
- Destructure props (Vue 3.5 style)
|
||||
- Use ref/reactive for state
|
||||
- Implement computed() for derived state
|
||||
- Use provide/inject for dependency injection
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
- Events promote loose coupling
|
||||
|
||||
## UI Framework
|
||||
|
||||
- Deprecated PrimeVue component replacements:
|
||||
- Dropdown → Select
|
||||
- OverlayPanel → Popover
|
||||
- Calendar → DatePicker
|
||||
- InputSwitch → ToggleSwitch
|
||||
- Sidebar → Drawer
|
||||
- Chips → AutoComplete with multiple enabled
|
||||
- TabMenu → Tabs without panels
|
||||
- Steps → Stepper without panels
|
||||
- InlineMessage → Message
|
||||
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS only (no custom CSS)
|
||||
- Use the correct tokens from style.css in the design system package
|
||||
- For common operations, try to use existing VueUse composables that automatically handle effect scope
|
||||
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
|
||||
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Extract complex conditionals to computed
|
||||
- Implement cleanup for async operations
|
||||
- Use vue-i18n for ALL UI strings
|
||||
- Use lifecycle hooks: onMounted, onUpdated
|
||||
- Use Teleport/Suspense when needed
|
||||
- Proper props and emits definitions
|
||||
<!-- "Play nice with others," mother always said,
|
||||
But Claude prefers its own file name instead. -->
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="splitter-overlay-root pointer-events-none flex flex-col">
|
||||
<div
|
||||
class="w-full h-full absolute top-0 left-0 z-999 pointer-events-none flex flex-col"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
@@ -14,69 +16,96 @@
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
key="main-splitter-stable"
|
||||
class="splitter-overlay flex-1 overflow-hidden"
|
||||
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
|
||||
:state-key="sidebarStateKey || 'main-splitter'"
|
||||
:key="splitterRefreshKey"
|
||||
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
|
||||
:state-key="sidebarStateKey"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="sidebarLocation === 'left'"
|
||||
class="side-bar-panel pointer-events-auto"
|
||||
:min-size="10"
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="sidebarLocation === 'left' ? 10 : 15"
|
||||
:size="20"
|
||||
:style="{
|
||||
display:
|
||||
sidebarPanelVisible && sidebarLocation === 'left'
|
||||
? 'flex'
|
||||
: 'none'
|
||||
}"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarPanelVisible && sidebarLocation === 'left'"
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="80" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible="sidebarPanelVisible" />
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
|
||||
class="bg-transparent pointer-events-none border-none splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
'rounded-tl-lg rounded-tr-lg ' +
|
||||
(bottomPanelVisible ? '' : 'hidden')
|
||||
cn(
|
||||
'rounded-tl-lg rounded-tr-lg ',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible"
|
||||
class="bottom-panel pointer-events-auto rounded-lg"
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel border border-(--p-panel-border-color) max-w-full overflow-x-auto bg-comfy-menu-bg pointer-events-auto rounded-lg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="sidebarLocation === 'right'"
|
||||
class="side-bar-panel pointer-events-auto"
|
||||
:min-size="10"
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="sidebarLocation === 'right' ? 10 : 15"
|
||||
:size="20"
|
||||
:style="{
|
||||
display:
|
||||
sidebarPanelVisible && sidebarLocation === 'right'
|
||||
? 'flex'
|
||||
: 'none'
|
||||
}"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-if="sidebarPanelVisible && sidebarLocation === 'right'"
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
@@ -86,15 +115,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
@@ -103,28 +142,52 @@ const unifiedWidth = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.UnifiedWidth')
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(
|
||||
() => useSidebarTabStore().activeSidebarTab !== null
|
||||
)
|
||||
const bottomPanelVisible = computed(
|
||||
() => useBottomPanelStore().bottomPanelVisible
|
||||
)
|
||||
const activeSidebarTabId = computed(
|
||||
() => useSidebarTabStore().activeSidebarTabId
|
||||
)
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
|
||||
const sidebarStateKey = computed(() => {
|
||||
if (unifiedWidth.value) {
|
||||
return 'unified-sidebar'
|
||||
return unifiedWidth.value
|
||||
? 'unified-sidebar'
|
||||
: // When no tab is active, use a default key to maintain state
|
||||
(activeSidebarTabId.value ?? 'default-sidebar')
|
||||
})
|
||||
|
||||
/**
|
||||
* Avoid triggering default behaviors during drag-and-drop, such as text selection.
|
||||
*/
|
||||
function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/*
|
||||
* Force refresh the splitter when right panel visibility or sidebar location changes
|
||||
* to recalculate the width and panel order
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
// When no tab is active, use a default key to maintain state
|
||||
return activeSidebarTabId.value ?? 'default-sidebar'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-splitter-gutter) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -135,32 +198,13 @@ const sidebarStateKey = computed(() => {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.side-bar-panel {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.bottom-panel {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-panel-border-color);
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
/* Hide sidebar gutter when sidebar is not visible */
|
||||
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.splitter-overlay {
|
||||
@apply bg-transparent pointer-events-none border-none;
|
||||
}
|
||||
|
||||
.splitter-overlay-root {
|
||||
@apply w-full h-full absolute top-0 left-0;
|
||||
|
||||
/* Set it the same as the ComfyUI menu */
|
||||
/* Note: Lite-graph DOM widgets have the same z-index as the node id, so
|
||||
999 should be sufficient to make sure splitter overlays on node's DOM
|
||||
widgets */
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="workspaceState.focusMode"
|
||||
class="comfy-menu-hamburger no-drag top-0 right-0"
|
||||
class="fixed z-9999 flex flex-row no-drag top-0 right-0"
|
||||
>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
size="large"
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
:aria-label="$t('menu.showMenu')"
|
||||
aria-live="assertive"
|
||||
@click="exitFocusMode"
|
||||
@contextmenu="showNativeSystemMenu"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
</Button>
|
||||
<div class="window-actions-spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -44,11 +44,3 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.comfy-menu-hamburger {
|
||||
@apply fixed z-9999 flex flex-row;
|
||||
}
|
||||
</style>
|
||||
|
||||
232
src/components/TopMenuSection.test.ts
Normal file
232
src/components/TopMenuSection.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => {
|
||||
return {
|
||||
isLoggedIn: computed(() => mockData.isLoggedIn)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil')
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
currentUser: null,
|
||||
loading: false
|
||||
}))
|
||||
}))
|
||||
|
||||
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
viewJobHistory: 'View job history',
|
||||
expandCollapsedQueue: 'Expand collapsed queue',
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TopMenuSection, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createJob(id: string, status: JobStatus): JobListItem {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(createJob(id, status))
|
||||
}
|
||||
|
||||
describe('TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when user is not logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = false
|
||||
})
|
||||
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the active jobs label with the current count', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
queueStore.runningTasks = [
|
||||
createTask('running-1', 'in_progress'),
|
||||
createTask('running-2', 'in_progress')
|
||||
]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
const commandStore = useCommandStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.Queue.ToggleOverlay'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
})
|
||||
|
||||
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.label).toBe('Clear queue')
|
||||
expect(model[0]?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,42 +10,84 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:class="queueHistoryButtonBackgroundClass"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--history] block size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
<Button
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
@@ -54,38 +96,103 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
() => !isQueuePanelV2Enabled.value
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const queueHistoryButtonBackgroundClass = computed(() =>
|
||||
isQueueOverlayExpanded.value
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'bg-secondary-background'
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
@@ -98,12 +205,39 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('assets')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
showToastOnLegacyError: false
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
toastErrorHandler(error)
|
||||
} catch (toastError) {
|
||||
console.error(error)
|
||||
console.error(toastError)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actionbar-container {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center">
|
||||
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
|
||||
<div
|
||||
v-if="isDragging && !isDocked"
|
||||
:class="actionbarClass"
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto z-1010"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
@@ -18,18 +18,29 @@
|
||||
content: { class: isDocked ? 'p-0' : 'p-1' }
|
||||
}"
|
||||
>
|
||||
<div ref="panelRef" class="flex items-center select-none">
|
||||
<div ref="panelRef" class="flex items-center select-none gap-2">
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle cursor-grab w-3 h-max mr-2',
|
||||
'drag-handle cursor-grab w-3 h-max',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ComfyRunButton />
|
||||
<Suspense @resolve="comfyRunButtonResolved">
|
||||
<ComfyRunButton />
|
||||
</Suspense>
|
||||
<Button
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -43,22 +54,29 @@ import {
|
||||
watchDebounced
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { t } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
|
||||
const tabContainer = document.querySelector('.workflow-tabs-container')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
@@ -66,22 +84,10 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
style: style,
|
||||
isDragging
|
||||
} = useDraggable(panelRef, {
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body,
|
||||
onMove: (event) => {
|
||||
// Prevent dragging the menu over the top of the tabs
|
||||
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
|
||||
if (event.y < minY) {
|
||||
event.y = minY
|
||||
}
|
||||
}
|
||||
containerElement: document.body
|
||||
})
|
||||
|
||||
// Update storedPosition when x or y changes
|
||||
@@ -126,7 +132,14 @@ const setInitialPosition = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(setInitialPosition)
|
||||
|
||||
//The ComfyRunButton is a dynamic import. Which means it will not be loaded onMount in this component.
|
||||
//So we must use suspense resolve to ensure that is has loaded and updated the DOM before calling setInitialPosition()
|
||||
async function comfyRunButtonResolved() {
|
||||
await nextTick()
|
||||
setInitialPosition()
|
||||
}
|
||||
|
||||
watch(visible, async (newVisible) => {
|
||||
if (newVisible) {
|
||||
await nextTick(setInitialPosition)
|
||||
@@ -255,6 +268,16 @@ watch(isDragging, (dragging) => {
|
||||
isMouseOverDropZone.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
@@ -267,10 +290,10 @@ const actionbarClass = computed(() =>
|
||||
)
|
||||
const panelClass = computed(() =>
|
||||
cn(
|
||||
'actionbar pointer-events-auto z1000',
|
||||
'actionbar pointer-events-auto z-1300',
|
||||
isDragging.value && 'select-none pointer-events-none',
|
||||
isDocked.value
|
||||
? 'p-0 static mr-2 border-none bg-transparent'
|
||||
? 'p-0 static border-none bg-transparent'
|
||||
: 'fixed shadow-interface'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -22,12 +22,13 @@
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
:variant="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="sm"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" />
|
||||
{{ String(item.label ?? '') }}
|
||||
</Button>
|
||||
</template>
|
||||
</SplitButton>
|
||||
<BatchCountEdit />
|
||||
@@ -36,25 +37,30 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import SplitButton from 'primevue/splitbutton'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
class="bg-transparent"
|
||||
>
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-container font-inter">
|
||||
<Tab
|
||||
v-for="tab in bottomPanelStore.bottomPanelTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="m-1 mx-2 border-none"
|
||||
class="m-1 mx-2 border-none font-inter"
|
||||
:class="{
|
||||
'tab-list-single-item':
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
@@ -46,21 +46,22 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isShortcutsTabActive"
|
||||
:label="$t('shortcuts.manageShortcuts')"
|
||||
icon="pi pi-cog"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
@click="openKeybindingSettings"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-cog" />
|
||||
{{ $t('shortcuts.manageShortcuts') }}
|
||||
</Button>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="closeBottomPanel"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabList>
|
||||
@@ -79,7 +80,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Tab from 'primevue/tab'
|
||||
import type { TabPassThroughMethodOptions } from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
@@ -88,6 +88,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock ShortcutsList component
|
||||
vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
|
||||
default: {
|
||||
name: 'ShortcutsList',
|
||||
props: ['commands', 'subcategories', 'columns'],
|
||||
template:
|
||||
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock command store
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
label: 'New Workflow',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Node.Add',
|
||||
label: 'Add Node',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Queue.Clear',
|
||||
label: 'Clear Queue',
|
||||
category: 'essentials'
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Other.Command',
|
||||
label: 'Other Command',
|
||||
category: 'view-controls',
|
||||
function: vi.fn(),
|
||||
icon: 'pi pi-test',
|
||||
tooltip: 'Test tooltip',
|
||||
menubarLabel: 'Other Command',
|
||||
keybinding: null
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: mockCommands
|
||||
})
|
||||
}))
|
||||
|
||||
describe('EssentialsPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('should render ShortcutsList with essentials commands', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
expect(shortcutsList.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should categorize commands into subcategories', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
const subcategories = shortcutsList.props('subcategories')
|
||||
|
||||
expect(subcategories).toHaveProperty('workflow')
|
||||
expect(subcategories).toHaveProperty('node')
|
||||
expect(subcategories).toHaveProperty('queue')
|
||||
|
||||
expect(subcategories.workflow).toContain(mockCommands[0])
|
||||
expect(subcategories.node).toContain(mockCommands[1])
|
||||
expect(subcategories.queue).toContain(mockCommands[2])
|
||||
})
|
||||
})
|
||||
164
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.test.ts
Normal file
164
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock vue-i18n
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'shortcuts.subcategories.workflow': 'Workflow',
|
||||
'shortcuts.subcategories.node': 'Node',
|
||||
'shortcuts.subcategories.queue': 'Queue',
|
||||
'shortcuts.subcategories.view': 'View',
|
||||
'shortcuts.subcategories.panelControls': 'Panel Controls',
|
||||
'commands.Workflow_New.label': 'New Blank Workflow'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: mockT
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ShortcutsList', () => {
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
label: 'New Workflow',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Control', 'n']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Node.Add',
|
||||
label: 'Add Node',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Shift', 'a']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl,
|
||||
{
|
||||
id: 'Queue.Clear',
|
||||
label: 'Clear Queue',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Control', 'Shift', 'c']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const mockSubcategories = {
|
||||
workflow: [mockCommands[0]],
|
||||
node: [mockCommands[1]],
|
||||
queue: [mockCommands[2]]
|
||||
}
|
||||
|
||||
it('should render shortcuts organized by subcategories', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check that subcategories are rendered
|
||||
expect(wrapper.text()).toContain('Workflow')
|
||||
expect(wrapper.text()).toContain('Node')
|
||||
expect(wrapper.text()).toContain('Queue')
|
||||
|
||||
// Check that commands are rendered
|
||||
expect(wrapper.text()).toContain('New Blank Workflow')
|
||||
})
|
||||
|
||||
it('should format keyboard shortcuts correctly', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check for formatted keys
|
||||
expect(wrapper.text()).toContain('Ctrl')
|
||||
expect(wrapper.text()).toContain('n')
|
||||
expect(wrapper.text()).toContain('Shift')
|
||||
expect(wrapper.text()).toContain('a')
|
||||
expect(wrapper.text()).toContain('c')
|
||||
})
|
||||
|
||||
it('should filter out commands without keybindings', () => {
|
||||
const commandsWithoutKeybinding: ComfyCommandImpl[] = [
|
||||
...mockCommands,
|
||||
{
|
||||
id: 'No.Keybinding',
|
||||
label: 'No Keybinding',
|
||||
category: 'essentials',
|
||||
keybinding: null
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: commandsWithoutKeybinding,
|
||||
subcategories: {
|
||||
...mockSubcategories,
|
||||
other: [commandsWithoutKeybinding[3]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('No Keybinding')
|
||||
})
|
||||
|
||||
it('should handle special key formatting', () => {
|
||||
const specialKeyCommand: ComfyCommandImpl = {
|
||||
id: 'Special.Keys',
|
||||
label: 'Special Keys',
|
||||
category: 'essentials',
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
|
||||
}
|
||||
}
|
||||
} as ComfyCommandImpl
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: [specialKeyCommand],
|
||||
subcategories: {
|
||||
special: [specialKeyCommand]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Cmd') // Meta -> Cmd
|
||||
expect(text).toContain('↑') // ArrowUp -> ↑
|
||||
expect(text).toContain('↵') // Enter -> ↵
|
||||
expect(text).toContain('Esc') // Escape -> Esc
|
||||
expect(text).toContain('Space') // ' ' -> Space
|
||||
})
|
||||
|
||||
it('should use fallback subcategory titles', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: {
|
||||
unknown: [mockCommands[0]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('unknown')
|
||||
})
|
||||
})
|
||||
@@ -55,7 +55,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { subcategories } = defineProps<{
|
||||
commands: ComfyCommandImpl[]
|
||||
subcategories: Record<string, ComfyCommandImpl[]>
|
||||
}>()
|
||||
|
||||
|
||||
220
src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
Normal file
220
src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
|
||||
|
||||
// Mock xterm and related modules
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onSelectionChange: vi.fn(() => {
|
||||
// Return a disposable
|
||||
return {
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ''),
|
||||
selectAll: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
loadAddon: vi.fn()
|
||||
})),
|
||||
IDisposable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({
|
||||
fit: vi.fn(),
|
||||
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 }))
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockTerminal = {
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onSelectionChange: vi.fn(() => ({
|
||||
dispose: vi.fn()
|
||||
})),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ''),
|
||||
selectAll: vi.fn(),
|
||||
clearSelection: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
|
||||
useTerminal: vi.fn(() => ({
|
||||
terminal: mockTerminal,
|
||||
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
isElectron: vi.fn(() => false),
|
||||
electronAPI: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
// Mock clipboard API
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
serverStart: {
|
||||
copySelectionTooltip: 'Copy selection',
|
||||
copyAllTooltip: 'Copy all'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountBaseTerminal = () => {
|
||||
return mount(BaseTerminal, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>',
|
||||
props: ['icon', 'severity', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('BaseTerminal', () => {
|
||||
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
it('emits created event on mount', () => {
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
expect(wrapper.emitted('created')).toBeTruthy()
|
||||
expect(wrapper.emitted('created')![0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('emits unmounted event on unmount', () => {
|
||||
wrapper = mountBaseTerminal()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(wrapper.emitted('unmounted')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('button exists and has correct initial state', async () => {
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
expect(button.exists()).toBe(true)
|
||||
|
||||
expect(button.classes()).toContain('opacity-0')
|
||||
expect(button.classes()).toContain('pointer-events-none')
|
||||
})
|
||||
|
||||
it('shows correct tooltip when no selection', async () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
expect(button.attributes('aria-label')).toBe('Copy all')
|
||||
})
|
||||
|
||||
it('shows correct tooltip when selection exists', async () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
// Trigger the selection change callback that was registered during mount
|
||||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
||||
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
||||
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
|
||||
const selectionCallback = mockCalls[0][0] as () => void
|
||||
selectionCallback()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
expect(button.attributes('aria-label')).toBe('Copy selection')
|
||||
})
|
||||
|
||||
it('copies selected text when selection exists', async () => {
|
||||
const selectedText = 'selected text'
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
mockTerminal.getSelection.mockReturnValue(selectedText)
|
||||
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
|
||||
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies all text when no selection exists', async () => {
|
||||
const allText = 'all terminal content'
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
mockTerminal.getSelection
|
||||
.mockReturnValueOnce('') // First call returns empty (no selection)
|
||||
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
|
||||
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockTerminal.selectAll).toHaveBeenCalled()
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
|
||||
expect(mockTerminal.clearSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when no text available', async () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
mockTerminal.getSelection.mockReturnValue('')
|
||||
|
||||
wrapper = mountBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockTerminal.selectAll).toHaveBeenCalled()
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,8 @@
|
||||
value: tooltipText,
|
||||
showDelay: 300
|
||||
}"
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:class="
|
||||
cn('absolute top-2 right-8 transition-opacity', {
|
||||
'opacity-0 pointer-events-none select-none': !isHovered
|
||||
@@ -21,18 +20,20 @@
|
||||
"
|
||||
:aria-label="tooltipText"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-copy" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover, useEventListener } from '@vueuse/core'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
@@ -32,27 +32,22 @@ const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
// `autoCols` is false because we don't want the progress bar in the terminal
|
||||
// to render incorrectly as the progress bar is rendered based on the
|
||||
// server's terminal size.
|
||||
// Apply a min cols of 80 for colab environments
|
||||
// Auto-size terminal to fill container width.
|
||||
// minCols: 80 ensures minimum width for colab environments.
|
||||
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
|
||||
useAutoSize({ root, autoRows: true, autoCols: false, minCols: 80 })
|
||||
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
|
||||
|
||||
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
|
||||
if (size) {
|
||||
terminal.resize(size.cols, terminal.rows)
|
||||
}
|
||||
const update = (entries: Array<LogEntry>) => {
|
||||
terminal.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||
update(e.detail.entries, e.detail.size)
|
||||
update(e.detail.entries)
|
||||
}
|
||||
|
||||
const loadLogEntries = async () => {
|
||||
const logs = await api.getRawLogs()
|
||||
update(logs.entries, logs.size)
|
||||
update(logs.entries)
|
||||
}
|
||||
|
||||
const watchLogs = async () => {
|
||||
|
||||
82
src/components/boundingbox/WidgetBoundingBox.vue
Normal file
82
src/components/boundingbox/WidgetBoundingBox.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||
})
|
||||
|
||||
const x = computed({
|
||||
get: () => modelValue.value.x,
|
||||
set: (x) => {
|
||||
modelValue.value = { ...modelValue.value, x }
|
||||
}
|
||||
})
|
||||
|
||||
const y = computed({
|
||||
get: () => modelValue.value.y,
|
||||
set: (y) => {
|
||||
modelValue.value = { ...modelValue.value, y }
|
||||
}
|
||||
})
|
||||
|
||||
const width = computed({
|
||||
get: () => modelValue.value.width,
|
||||
set: (width) => {
|
||||
modelValue.value = { ...modelValue.value, width }
|
||||
}
|
||||
})
|
||||
|
||||
const height = computed({
|
||||
get: () => modelValue.value.height,
|
||||
set: (height) => {
|
||||
modelValue.value = { ...modelValue.value, height }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +13,37 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleBackClick"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
</Button>
|
||||
<Breadcrumb
|
||||
ref="breadcrumbRef"
|
||||
class="w-fit rounded-lg p-0"
|
||||
:class="{ hidden: !isInSubgraph }"
|
||||
:model="items"
|
||||
:pt="{ item: { class: 'pointer-events-auto' } }"
|
||||
:aria-label="$t('g.graphNavigation')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item === items.at(-1)"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
</template>
|
||||
<template #separator
|
||||
@@ -35,6 +55,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import Button from 'primevue/button'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
@@ -43,6 +64,7 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -55,6 +77,12 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -62,17 +90,28 @@ const isBlueprint = computed(() =>
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
|
||||
const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
@@ -95,21 +134,26 @@ const items = computed(() => {
|
||||
return [home.value, ...items]
|
||||
})
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
@@ -189,13 +233,18 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden;
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s;
|
||||
/* Collapse middle items first */
|
||||
flex-shrink: 10000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator) {
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
padding: 0 var(--p-breadcrumb-item-margin);
|
||||
}
|
||||
@@ -205,11 +254,9 @@ onUpdated(() => {
|
||||
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator),
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply h-12;
|
||||
border-top: 1px solid var(--interface-stroke);
|
||||
border-bottom: 1px solid var(--interface-stroke);
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
@@ -218,10 +265,8 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:first-child) {
|
||||
@apply rounded-l-lg;
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
border-left: 1px solid var(--interface-stroke);
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
padding-left: var(--p-breadcrumb-item-padding);
|
||||
@@ -229,13 +274,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:last-child) {
|
||||
@apply rounded-r-lg;
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
border-right: 1px solid var(--interface-stroke);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item-link:hover),
|
||||
:deep(.p-breadcrumb-item-link-menu-visible) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}"
|
||||
draggable="false"
|
||||
href="#"
|
||||
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
|
||||
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
@@ -21,11 +21,11 @@
|
||||
class="icon-[lucide--triangle-alert] text-warning-background"
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive"
|
||||
v-if="isActive || isRoot"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
@@ -59,16 +59,19 @@ import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -79,7 +82,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
@@ -130,79 +136,28 @@ const tooltipText = computed(() => {
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: startRename
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
const startRename = async () => {
|
||||
// Check if element is hidden (collapsed breadcrumb)
|
||||
// When collapsed, root item is hidden via CSS display:none, so use rename command
|
||||
if (isRoot && wrapperRef.value?.offsetParent === null) {
|
||||
await useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
return
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isEditing.value) {
|
||||
@@ -223,20 +178,6 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const startRename = () => {
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
@@ -244,6 +185,14 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Components/Button/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md']
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--trophy] size-4" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'transparent',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<i class="icon-[lucide--bell] size-3" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
type: 'secondary',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { IconButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="sm" @click="() => {}">
|
||||
<i class="icon-[lucide--trophy] size-3" />
|
||||
</IconButton>
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--trophy] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="secondary" size="sm" @click="() => {}">
|
||||
<i class="icon-[lucide--settings] size-3" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="transparent" size="sm" @click="() => {}">
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--bell] size-4" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--heart] size-4" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonTypeClasses,
|
||||
getIconButtonSizeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
onClick
|
||||
} = defineProps<IconButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} p-0`
|
||||
const sizeClasses = getIconButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconGroup from './IconGroup.vue'
|
||||
|
||||
const meta: Meta<typeof IconGroup> = {
|
||||
@@ -16,18 +16,18 @@ type Story = StoryObj<typeof IconGroup>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { IconGroup, IconButton },
|
||||
components: { IconGroup, Button },
|
||||
template: `
|
||||
<IconGroup>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<Button size="icon" @click="console.log('Hello World!!')">
|
||||
<i class="icon-[lucide--heart] size-4" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
</Button>
|
||||
<Button size="icon" @click="console.log('Hello World!!')">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
</Button>
|
||||
<Button size="icon" @click="console.log('Hello World!!')">
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</IconButton>
|
||||
</Button>
|
||||
</IconGroup>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div :class="iconGroupClasses">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
|
||||
backgroundClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,11 +14,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const iconGroupClasses = cn(
|
||||
'flex justify-center items-center shrink-0',
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-secondary-background shadow-sm',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
const { backgroundClass } = defineProps<{
|
||||
backgroundClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
|
||||
const meta: Meta<typeof IconTextButton> = {
|
||||
title: 'Components/Button/IconTextButton',
|
||||
component: IconTextButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md']
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['left', 'right']
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--package] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Deploy',
|
||||
type: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Settings',
|
||||
type: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Cancel',
|
||||
type: 'transparent',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithIconRight: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Next',
|
||||
type: 'primary',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--save] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Save',
|
||||
type: 'primary',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
IconTextButton
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--settings] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
<slot v-if="iconPosition === 'right'" name="icon"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface IconTextButtonProps extends BaseButtonProps {
|
||||
iconPosition?: 'left' | 'right'
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
iconPosition = 'left',
|
||||
label,
|
||||
onClick
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MoreButton from './MoreButton.vue'
|
||||
|
||||
const meta: Meta<typeof MoreButton> = {
|
||||
@@ -17,30 +17,26 @@ type Story = StoryObj<typeof MoreButton>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { MoreButton, IconTextButton },
|
||||
components: { MoreButton, Button },
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
label="Settings"
|
||||
<Button
|
||||
variant="textonly"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
<span>Settings</span>
|
||||
</Button>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
label="Profile"
|
||||
<Button
|
||||
variant="textonly"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--scroll-text] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<i class="icon-[lucide--scroll-text] size-4" />
|
||||
<span>Profile</span>
|
||||
</Button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
v-bind="$attrs"
|
||||
@click="popover?.toggle"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
!isVertical
|
||||
? 'icon-[lucide--ellipsis]'
|
||||
: 'icon-[lucide--more-vertical]',
|
||||
'text-sm'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
ref="popover"
|
||||
@@ -25,8 +38,18 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
@show="
|
||||
() => {
|
||||
isOpen = true
|
||||
$emit('menuOpened')
|
||||
}
|
||||
"
|
||||
@hide="
|
||||
() => {
|
||||
isOpen = false
|
||||
$emit('menuClosed')
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
@@ -39,37 +62,33 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface MoreButtonProps extends BaseButtonProps {
|
||||
interface MoreButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
isVertical = false
|
||||
} = defineProps<MoreButtonProps>()
|
||||
const { isVertical = false } = defineProps<MoreButtonProps>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
const isOpen = ref(false)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const hide = () => {
|
||||
function hide() {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
hide
|
||||
hide,
|
||||
isOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TextButton from './TextButton.vue'
|
||||
|
||||
const meta: Meta<typeof TextButton> = {
|
||||
title: 'Components/Button/TextButton',
|
||||
component: TextButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
defaultValue: 'Click me'
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md'],
|
||||
defaultValue: 'md'
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent'],
|
||||
defaultValue: 'primary'
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
label: 'Primary Button',
|
||||
type: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
label: 'Secondary Button',
|
||||
type: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
args: {
|
||||
label: 'Transparent Button',
|
||||
type: 'transparent',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
label: 'Small Button',
|
||||
type: 'primary',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { TextButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<TextButton label="Primary Small" type="primary" size="sm" @click="() => {}" />
|
||||
<TextButton label="Primary Medium" type="primary" size="md" @click="() => {}" />
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<TextButton label="Secondary Small" type="secondary" size="sm" @click="() => {}" />
|
||||
<TextButton label="Secondary Medium" type="secondary" size="md" @click="() => {}" />
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<TextButton label="Transparent Small" type="transparent" size="sm" @click="() => {}" />
|
||||
<TextButton label="Transparent Medium" type="transparent" size="md" @click="() => {}" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TextButtonProps extends BaseButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
label,
|
||||
onClick
|
||||
} = defineProps<TextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = getBaseButtonClasses()
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
})
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconButton from '../button/IconButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SquareChip from '../chip/SquareChip.vue'
|
||||
import CardBottom from './CardBottom.vue'
|
||||
import CardContainer from './CardContainer.vue'
|
||||
@@ -173,7 +173,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
Button,
|
||||
SquareChip
|
||||
},
|
||||
setup() {
|
||||
@@ -222,19 +222,19 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template v-if="args.showTopRight" #top-right>
|
||||
<IconButton
|
||||
<Button
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info clicked')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
</Button>
|
||||
<Button
|
||||
class="!bg-white/90"
|
||||
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
|
||||
</IconButton>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="args.showBottomLeft" #bottom-left>
|
||||
|
||||
@@ -7,20 +7,24 @@
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="$t('g.upload')"
|
||||
:icon="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'"
|
||||
size="small"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="$t('g.upload')"
|
||||
:disabled="isUploading"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
>
|
||||
<i :class="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.clear')"
|
||||
outlined
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
size="small"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="$t('g.clear')"
|
||||
:disabled="!modelValue"
|
||||
@click="clearImage"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</Button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -32,10 +36,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
|
||||
@@ -27,24 +27,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
:label="$t('g.reset')"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-text"
|
||||
@click="resetCustomization"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('g.confirm')"
|
||||
icon="pi pi-check"
|
||||
autofocus
|
||||
@click="confirmCustomization"
|
||||
/>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
@@ -52,6 +47,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).setValue('New Text')
|
||||
await wrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.enter')
|
||||
// Blur event should have been triggered
|
||||
expect(wrapper.findComponent(InputText).element).not.toBe(
|
||||
document.activeElement
|
||||
@@ -79,7 +79,7 @@ describe('EditableText', () => {
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
|
||||
// Should emit cancel event
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
@@ -103,7 +103,7 @@ describe('EditableText', () => {
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape (which triggers blur internally)
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
|
||||
// Manually trigger blur to simulate the blur that happens after escape
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
@@ -120,7 +120,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
||||
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
|
||||
// Trigger blur that happens after enter
|
||||
await enterWrapper.findComponent(InputText).trigger('blur')
|
||||
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
||||
@@ -133,7 +133,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span v-if="!isEditing">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
@@ -18,9 +18,10 @@
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@keydown.enter.capture.stop="blurInputElement"
|
||||
@keydown.escape.capture.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
/>
|
||||
@@ -38,10 +39,10 @@ const {
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, any>
|
||||
inputAttrs?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const isCanceling = ref(false)
|
||||
|
||||
@@ -16,20 +16,22 @@
|
||||
<Button
|
||||
v-if="status === null || status === 'error'"
|
||||
class="file-action-button"
|
||||
:label="$t('g.download') + ' (' + fileSize + ')'"
|
||||
size="small"
|
||||
outlined
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
icon="pi pi-download"
|
||||
@click="triggerDownload"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
{{ $t('g.download') + ' (' + fileSize + ')' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="(status === null || status === 'error') && !!props.url"
|
||||
:label="$t('g.copyURL')"
|
||||
size="small"
|
||||
outlined
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="copyURL"
|
||||
/>
|
||||
>
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -49,44 +51,48 @@
|
||||
v-if="status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
icon="pi pi-pause-circle"
|
||||
@click="triggerPauseDownload"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-pause-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
:disabled="!!props.error"
|
||||
icon="pi pi-play-circle"
|
||||
@click="triggerResumeDownload"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-play-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
:disabled="!!props.error"
|
||||
icon="pi pi-times-circle"
|
||||
severity="danger"
|
||||
@click="triggerCancelDownload"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-times-circle" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
@@ -22,31 +22,27 @@
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:label="$t('g.download') + ' (' + fileSize + ')'"
|
||||
size="small"
|
||||
outlined
|
||||
variant="secondary"
|
||||
:disabled="!!props.error"
|
||||
:title="props.url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
/>
|
||||
>
|
||||
{{ $t('g.download') + ' (' + fileSize + ')' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:label="$t('g.copyURL')"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="copyURL"
|
||||
/>
|
||||
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
@@ -11,7 +11,6 @@ import InputText from 'primevue/inputtext'
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
defineProps<{
|
||||
defaultValue?: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
|
||||
@@ -14,21 +14,20 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
icon="pi pi-upload"
|
||||
:label="$t('g.upload')"
|
||||
size="small"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
<Button size="sm" @click="triggerFileInput">
|
||||
<i class="pi pi-upload" />
|
||||
{{ $t('g.upload') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
class="w-full"
|
||||
outlined
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
size="small"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click="clearImage"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -42,9 +41,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createApp } from 'vue'
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('FormRadioGroup', () => {
|
||||
beforeAll(() => {
|
||||
@@ -14,7 +15,8 @@ describe('FormRadioGroup', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
@@ -92,9 +94,9 @@ describe('FormRadioGroup', () => {
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: 1 },
|
||||
{ name: 'Second Option', id: 2 },
|
||||
{ name: 'Third Option', id: 3 }
|
||||
{ name: 'First Option', id: '1' },
|
||||
{ name: 'Second Option', id: '2' },
|
||||
{ name: 'Third Option', id: '3' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
@@ -108,9 +110,9 @@ describe('FormRadioGroup', () => {
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe(1)
|
||||
expect(radioButtons[1].props('value')).toBe(2)
|
||||
expect(radioButtons[2].props('value')).toBe(3)
|
||||
expect(radioButtons[0].props('value')).toBe('1')
|
||||
expect(radioButtons[1].props('value')).toBe('2')
|
||||
expect(radioButtons[2].props('value')).toBe('3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
@@ -167,10 +169,7 @@ describe('FormRadioGroup', () => {
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', val: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
@@ -179,11 +178,10 @@ describe('FormRadioGroup', () => {
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(2)
|
||||
expect(radioButtons).toHaveLength(1)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
expect(labels[1].text()).toBe('Option 2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any
|
||||
options: (SettingOption | string)[]
|
||||
options?: (string | SettingOption | Record<string, string>)[]
|
||||
optionLabel?: string
|
||||
optionValue?: string
|
||||
id?: string
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
/>
|
||||
<img
|
||||
v-if="cachedSrc"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
@@ -61,7 +60,6 @@ const {
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const isIntersecting = ref(false)
|
||||
const isImageLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
</p>
|
||||
<Button
|
||||
v-if="buttonLabel"
|
||||
:label="buttonLabel"
|
||||
class="p-button-text"
|
||||
variant="textonly"
|
||||
@click="$emit('action')"
|
||||
/>
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
@@ -21,9 +22,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
icon?: string
|
||||
|
||||
65
src/components/common/OverlayIcon.vue
Normal file
65
src/components/common/OverlayIcon.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<span class="relative inline-flex items-center justify-center size-[1em]">
|
||||
<i :class="mainIcon" class="text-[1em]" />
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
subIcon,
|
||||
'absolute leading-none pointer-events-none',
|
||||
positionX === 'left' ? 'left-0' : 'right-0',
|
||||
positionY === 'top' ? 'top-0' : 'bottom-0'
|
||||
)
|
||||
"
|
||||
:style="subIconStyle"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type Position = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export interface OverlayIconProps {
|
||||
mainIcon: string
|
||||
subIcon: string
|
||||
positionX?: Position
|
||||
positionY?: Position
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
subIconScale?: number
|
||||
}
|
||||
const {
|
||||
mainIcon,
|
||||
subIcon,
|
||||
positionX = 'right',
|
||||
positionY = 'bottom',
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
subIconScale = 0.6
|
||||
} = defineProps<OverlayIconProps>()
|
||||
|
||||
const textShadow = [
|
||||
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 1px 0 rgba(0, 0, 0, 0.7)`
|
||||
].join(', ')
|
||||
|
||||
const subIconStyle = computed(() => ({
|
||||
fontSize: `${subIconScale}em`,
|
||||
textShadow,
|
||||
...(offsetX !== 0 && {
|
||||
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
|
||||
}),
|
||||
...(offsetY !== 0 && {
|
||||
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
|
||||
})
|
||||
}))
|
||||
</script>
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchBox from './SearchBox.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: Omit<ComponentExposed<C>, 'focus'>
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchBox> = {
|
||||
const meta: GenericMeta<typeof SearchBox> = {
|
||||
title: 'Components/Input/SearchBox',
|
||||
component: SearchBox,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
@@ -19,9 +26,12 @@ const meta: Meta<typeof SearchBox> = {
|
||||
control: 'select',
|
||||
options: ['md', 'lg'],
|
||||
description: 'Size variant of the search box'
|
||||
}
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' },
|
||||
onSearch: { action: 'search' }
|
||||
},
|
||||
args: {
|
||||
modelValue: '',
|
||||
placeholder: 'Search...',
|
||||
showBorder: false,
|
||||
size: 'md'
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from './SearchBox.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -50,15 +50,15 @@ describe('SearchBox', () => {
|
||||
await input.setValue('test')
|
||||
|
||||
// Model should not update immediately
|
||||
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 299ms (just before debounce delay)
|
||||
vi.advanceTimersByTime(299)
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance timers by 1ms more (reaching 300ms)
|
||||
vi.advanceTimersByTime(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
// Model should now be updated
|
||||
@@ -82,19 +82,19 @@ describe('SearchBox', () => {
|
||||
|
||||
// Type third character (should reset timer again)
|
||||
await input.setValue('tes')
|
||||
vi.advanceTimersByTime(200)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet (only 200ms passed since last keystroke)
|
||||
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Advance final 100ms to reach 300ms
|
||||
vi.advanceTimersByTime(100)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
// Should now emit with final value
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['tes'])
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
@@ -105,19 +105,20 @@ describe('SearchBox', () => {
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
vi.advanceTimersByTime(50) // Less than debounce delay
|
||||
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Should not have emitted yet
|
||||
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
|
||||
// Complete the debounce delay
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
// Should emit only once with final value
|
||||
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['search'])
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
|
||||
})
|
||||
|
||||
describe('bidirectional model sync', () => {
|
||||
@@ -1,96 +1,125 @@
|
||||
<template>
|
||||
<div>
|
||||
<IconField>
|
||||
<Button
|
||||
v-if="filterIcon"
|
||||
class="p-inputicon filter-button"
|
||||
:icon="filterIcon"
|
||||
text
|
||||
severity="contrast"
|
||||
@click="$emit('showFilter', $event)"
|
||||
/>
|
||||
<InputText
|
||||
class="search-box-input w-full"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autofocus="autofocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
class="p-inputicon clear-button"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="contrast"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</IconField>
|
||||
<div
|
||||
v-if="filters?.length"
|
||||
class="search-filters flex flex-wrap gap-2 pt-2"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full items-center gap-2 bg-comfy-input cursor-text text-comfy-input-foreground',
|
||||
customClass,
|
||||
wrapperStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:placeholder
|
||||
:autofocus
|
||||
unstyled
|
||||
class="absolute inset-0 size-full pl-11 border-none outline-none bg-transparent text-sm"
|
||||
:aria-label="placeholder"
|
||||
/>
|
||||
<Button
|
||||
v-if="filterIcon"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="filter-button absolute right-0 inset-y-0 m-0 p-0"
|
||||
@click="$emit('showFilter', $event)"
|
||||
>
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="$emit('removeFilter', filter)"
|
||||
/>
|
||||
</div>
|
||||
<i :class="filterIcon" />
|
||||
</Button>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
class="clear-button absolute left-0"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@click="modelValue = ''"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:text="filter.text"
|
||||
:badge="filter.badge"
|
||||
:badge-class="filter.badgeClass"
|
||||
@remove="$emit('removeFilter', filter)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="TFilter extends SearchFilter">
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import Button from 'primevue/button'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import type { SearchFilter } from './SearchFilterChip.vue'
|
||||
import SearchFilterChip from './SearchFilterChip.vue'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
placeholder = 'Search...',
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = [],
|
||||
autofocus = false
|
||||
autofocus = false,
|
||||
showBorder = false,
|
||||
size = 'md',
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
showBorder?: boolean
|
||||
size?: 'md' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'search', value: string, filters: TFilter[]): void
|
||||
(e: 'showFilter', event: Event): void
|
||||
(e: 'removeFilter', filter: TFilter): void
|
||||
}>()
|
||||
|
||||
const emitSearch = debounce((value: string) => {
|
||||
emit('search', value, filters)
|
||||
}, debounceTime)
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
emitSearch(target.value)
|
||||
}
|
||||
const inputRef = ref()
|
||||
|
||||
const clearSearch = () => {
|
||||
emit('update:modelValue', '')
|
||||
emitSearch('')
|
||||
}
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value: string) => {
|
||||
emit('search', value, filters)
|
||||
},
|
||||
{ debounce: debounceTime }
|
||||
)
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
if (showBorder) {
|
||||
return cn('rounded p-2 border border-solid border-border-default')
|
||||
}
|
||||
|
||||
// Size-specific classes matching button sizes for consistency
|
||||
const sizeClasses = {
|
||||
md: 'h-8 px-2 py-1.5', // Matches button sm size
|
||||
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||
}[size]
|
||||
|
||||
return cn('rounded-lg', sizeClasses)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -99,8 +128,4 @@ const clearSearch = () => {
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
.p-button.p-inputicon {
|
||||
@apply p-0 w-auto border-none;
|
||||
}
|
||||
</style>
|
||||
|
||||
95
src/components/common/StatusBadge.stories.ts
Normal file
95
src/components/common/StatusBadge.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Common/StatusBadge',
|
||||
component: StatusBadge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Status',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof StatusBadge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
label: 'Failed',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Finished: Story = {
|
||||
args: {
|
||||
label: 'Finished',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Dot: Story = {
|
||||
args: {
|
||||
label: undefined,
|
||||
variant: 'dot',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeverities: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge label="Default" severity="default" />
|
||||
<StatusBadge label="Secondary" severity="secondary" />
|
||||
<StatusBadge label="Warn" severity="warn" />
|
||||
<StatusBadge label="Danger" severity="danger" />
|
||||
<StatusBadge label="Contrast" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="Label" variant="label" />
|
||||
<span class="text-xs text-muted">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
27
src/components/common/StatusBadge.vue
Normal file
27
src/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
<Tree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
selection-mode="single"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import UrlInput from './UrlInput.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
const mountComponent = (
|
||||
props: ComponentProps<typeof UrlInput> & {
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
},
|
||||
options = {}
|
||||
) => {
|
||||
return mount(UrlInput, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
|
||||
await input.setValue(' https://leading-space.com')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
|
||||
expect(input.element.value).toBe('https://leading-space.com')
|
||||
|
||||
// Test trailing whitespace
|
||||
await input.setValue('https://trailing-space.com ')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
|
||||
expect(input.element.value).toBe('https://trailing-space.com')
|
||||
|
||||
// Test both leading and trailing whitespace
|
||||
await input.setValue(' https://both-spaces.com ')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
|
||||
expect(input.element.value).toBe('https://both-spaces.com')
|
||||
|
||||
// Test whitespace in the middle of the URL
|
||||
await input.setValue('https:// middle-space.com')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
|
||||
expect(input.element.value).toBe('https://middle-space.com')
|
||||
})
|
||||
|
||||
it('trims whitespace when value set externally', async () => {
|
||||
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
|
||||
placeholder: 'Enter URL'
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Check initial value is trimmed
|
||||
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
|
||||
expect(input.element.value).toBe('https://initial-value.com')
|
||||
|
||||
// Update props with whitespace
|
||||
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
||||
await nextTick()
|
||||
|
||||
// Check updated value is trimmed
|
||||
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
|
||||
expect(input.element.value).toBe('https://updated-value.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
||||
return mount(UserAvatar, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
|
||||
134
src/components/common/UserCredit.test.ts
Normal file
134
src/components/common/UserCredit.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import UserCredit from './UserCredit.vue'
|
||||
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('pinia')
|
||||
|
||||
const mockBalance = vi.hoisted(() => ({
|
||||
value: {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
}))
|
||||
|
||||
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
balance: mockBalance.value,
|
||||
isFetchingBalance: mockIsFetchingBalance.value
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('UserCredit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBalance.value = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
mockIsFetchingBalance.value = false
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(UserCredit, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Skeleton: true,
|
||||
Tag: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('effective_balance_micros handling', () => {
|
||||
it('uses effective_balance_micros when present (positive balance)', () => {
|
||||
mockBalance.value = {
|
||||
amount_micros: 200_000,
|
||||
effective_balance_micros: 150_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('Credits')
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
mockBalance.value = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 0,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
mockBalance.value = {
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: -50_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('-')
|
||||
})
|
||||
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
mockBalance.value = {
|
||||
amount_micros: 100_000,
|
||||
currency: 'usd'
|
||||
} as typeof mockBalance.value
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('Credits')
|
||||
})
|
||||
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
mockBalance.value = {
|
||||
currency: 'usd'
|
||||
} as typeof mockBalance.value
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton when loading', () => {
|
||||
mockIsFetchingBalance.value = true
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,18 @@
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<Tag
|
||||
v-if="!showCreditsOnly"
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<div :class="textClass">{{ formattedBalance }}</div>
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,19 +27,42 @@
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
const { textClass } = defineProps<{
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
textClass?: string
|
||||
showCreditsOnly?: boolean
|
||||
}>()
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
if (!authStore.balance) return '0.00'
|
||||
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
|
||||
const cents =
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value
|
||||
})
|
||||
return `${amount} ${t('credits.credits')}`
|
||||
})
|
||||
|
||||
const formattedCreditsOnly = computed(() => {
|
||||
const cents =
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
|
||||
})
|
||||
return amount
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div ref="container" class="scroll-container">
|
||||
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
||||
<div :style="gridStyle">
|
||||
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
height: `${((items.length - state.end) / cols) * itemHeight}px`
|
||||
}"
|
||||
/>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,19 +32,22 @@ type GridState = {
|
||||
|
||||
const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200
|
||||
defaultItemWidth = 200,
|
||||
maxColumns = Infinity
|
||||
} = defineProps<{
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: Partial<CSSProperties>
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
maxColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
return {
|
||||
...gridStyle,
|
||||
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
|
||||
}
|
||||
})
|
||||
|
||||
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
||||
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
||||
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(rows: number): string {
|
||||
return `${(rows / cols.value) * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
}))
|
||||
const bottomSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(items.length - state.value.end)
|
||||
}))
|
||||
|
||||
whenever(
|
||||
() => state.value.isNearEnd,
|
||||
() => {
|
||||
@@ -109,24 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
onResize.cancel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
43
src/components/common/WorkspaceProfilePic.vue
Normal file
43
src/components/common/WorkspaceProfilePic.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
>
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
workspaceName: string
|
||||
}>()
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
const seed = letter.value.charCodeAt(0)
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function () {
|
||||
let t = (a += 0x6d2b79f5)
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const hue1 = Math.floor(rand() * 360)
|
||||
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
|
||||
const sat = 65 + Math.floor(rand() * 20)
|
||||
const light = 55 + Math.floor(rand() * 15)
|
||||
|
||||
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
|
||||
})
|
||||
</script>
|
||||
26
src/components/common/statusBadge.variants.ts
Normal file
26
src/components/common/statusBadge.variants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const statusBadgeVariants = cva({
|
||||
base: 'inline-flex items-center justify-center rounded-full',
|
||||
variants: {
|
||||
severity: {
|
||||
default: 'bg-primary-background text-base-foreground',
|
||||
secondary: 'bg-secondary-background text-base-foreground',
|
||||
warn: 'bg-warning-background text-base-background',
|
||||
danger: 'bg-destructive-background text-white',
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-xxxs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
severity: 'default',
|
||||
variant: 'label'
|
||||
}
|
||||
})
|
||||
|
||||
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>
|
||||
@@ -3,17 +3,14 @@
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
<h2 class="text-neutral text-base">
|
||||
{{ $t('sideToolbar.templates', 'Templates') }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[comfy--template]" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">{{
|
||||
$t('sideToolbar.templates', 'Templates')
|
||||
}}</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
@@ -22,16 +19,17 @@
|
||||
|
||||
<template #header-right-area>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton
|
||||
<Button
|
||||
v-if="filteredCount !== totalCount"
|
||||
type="secondary"
|
||||
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--filter-x]" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<i class="icon-[lucide--filter-x]" />
|
||||
<span>{{
|
||||
$t('templateWorkflows.resetFilters', 'Clear Filters')
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -174,6 +172,7 @@
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
@@ -301,16 +300,16 @@
|
||||
v-if="template.tutorialUrl"
|
||||
class="flex flex-col-reverse justify-center"
|
||||
>
|
||||
<IconButton
|
||||
<Button
|
||||
v-if="hoveredTemplate === template.name"
|
||||
v-tooltip.bottom="$t('g.seeTutorial')"
|
||||
v-bind="$attrs"
|
||||
type="primary"
|
||||
size="sm"
|
||||
variant="inverted"
|
||||
size="icon"
|
||||
@click.stop="openTutorial(template)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</IconButton>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,19 +381,18 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.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'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
@@ -405,6 +403,8 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
@@ -423,6 +423,30 @@ onMounted(() => {
|
||||
sessionStartTime.value = Date.now()
|
||||
})
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
case 'localhost':
|
||||
return [TemplateIncludeOnDistributionEnum.Local]
|
||||
case 'desktop':
|
||||
default:
|
||||
if (systemStatsStore.systemStats?.system.os === 'darwin') {
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Mac
|
||||
]
|
||||
}
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Windows
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
@@ -511,6 +535,9 @@ const allTemplates = computed(() => {
|
||||
return workflowTemplatesStore.enhancedTemplates
|
||||
})
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
if (!selectedNavItem.value) {
|
||||
@@ -533,9 +560,40 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Coordinates state between the selected navigation item and the sort order to
|
||||
* create deterministic, predictable behavior.
|
||||
* @param source The origin of the change ('nav' or 'sort').
|
||||
*/
|
||||
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
const isPopularNav = selectedNavItem.value === 'popular'
|
||||
const isPopularSort = sortBy.value === 'popular'
|
||||
|
||||
if (source === 'nav') {
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
||||
sortBy.value = 'popular'
|
||||
} else if (!isPopularNav && isPopularSort) {
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
// reset the category to 'All Templates' to avoid a confusing state.
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
selectedNavItem.value = 'all'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
const selectedModelObjects = computed({
|
||||
get() {
|
||||
@@ -578,9 +636,6 @@ const cardRefs = ref<HTMLElement[]>([])
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Search text for model filter
|
||||
const modelSearchText = ref<string>('')
|
||||
|
||||
@@ -645,11 +700,19 @@ const runsOnFilterLabel = computed(() => {
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
},
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
@@ -750,10 +813,10 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run both operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
])
|
||||
return true
|
||||
},
|
||||
@@ -763,6 +826,14 @@ const { isLoading } = useAsyncState(
|
||||
}
|
||||
)
|
||||
|
||||
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
|
||||
return (template.includeOnDistributions?.length ?? 0) > 0
|
||||
? distributions.value.some((d) =>
|
||||
template.includeOnDistributions?.includes(d)
|
||||
)
|
||||
: true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -14,6 +19,7 @@
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
@@ -35,11 +41,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}): DialogPassThroughOptions {
|
||||
const isWorkspaceSettingsDialog =
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled.value
|
||||
const basePt = item.dialogComponentProps.pt || {}
|
||||
|
||||
if (isWorkspaceSettingsDialog) {
|
||||
return merge(basePt, {
|
||||
mask: { class: 'p-8' }
|
||||
})
|
||||
}
|
||||
return basePt
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -55,6 +88,19 @@ const dialogStore = useDialogStore()
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
|
||||
19
src/components/dialog/confirm/ConfirmBody.vue
Normal file
19
src/components/dialog/confirm/ConfirmBody.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
{{ promptTextReal }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { promptText } = defineProps<{
|
||||
promptText?: MaybeRefOrGetter<string>
|
||||
}>()
|
||||
|
||||
const promptTextReal = computed(() => toValue(promptText))
|
||||
</script>
|
||||
40
src/components/dialog/confirm/ConfirmFooter.vue
Normal file
40
src/components/dialog/confirm/ConfirmFooter.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<section class="w-full flex gap-2 justify-end px-2 pb-2">
|
||||
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
|
||||
{{ cancelTextX }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled
|
||||
variant="textonly"
|
||||
:class="confirmClass"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmTextX }}
|
||||
</Button>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
confirmClass?: string
|
||||
optionsDisabled?: MaybeRefOrGetter<boolean>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
cancel: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const confirmTextX = computed(() => confirmText || t('g.confirm'))
|
||||
const cancelTextX = computed(() => cancelText || t('g.cancel'))
|
||||
const disabled = computed(() => toValue(optionsDisabled))
|
||||
</script>
|
||||
12
src/components/dialog/confirm/ConfirmHeader.vue
Normal file
12
src/components/dialog/confirm/ConfirmHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
|
||||
>
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
31
src/components/dialog/confirm/confirmDialog.ts
Normal file
31
src/components/dialog/confirm/confirmDialog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
headerProps?: ComponentAttrs<typeof ConfirmHeader>
|
||||
props?: ComponentAttrs<typeof ConfirmBody>
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
headerComponent: ConfirmHeader,
|
||||
component: ConfirmBody,
|
||||
footerComponent: ConfirmFooter,
|
||||
headerProps,
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -11,24 +11,25 @@
|
||||
<ApiNodesList :node-names="apiNodeNames" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Button :label="t('g.learnMore')" link @click="handleLearnMoreClick" />
|
||||
<Button variant="textonly" @click="handleLearnMoreClick">
|
||||
{{ t('g.learnMore') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:label="t('g.cancel')"
|
||||
outlined
|
||||
severity="secondary"
|
||||
@click="onCancel?.()"
|
||||
/>
|
||||
<Button :label="t('g.login')" @click="onLogin?.()" />
|
||||
<Button variant="secondary" @click="onCancel?.()">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
<Button @click="onLogin?.()">
|
||||
{{ t('g.login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -32,68 +32,72 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('g.cancel')"
|
||||
icon="pi pi-undo"
|
||||
severity="secondary"
|
||||
v-if="type !== 'info'"
|
||||
variant="secondary"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
/>
|
||||
<Button
|
||||
v-if="type === 'default'"
|
||||
:label="$t('g.confirm')"
|
||||
severity="primary"
|
||||
icon="pi pi-check"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-undo" />
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button v-if="type === 'default'" variant="primary" @click="onConfirm">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="type === 'delete'"
|
||||
:label="$t('g.delete')"
|
||||
severity="danger"
|
||||
icon="pi pi-trash"
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
{{ $t('g.delete') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="type === 'overwrite' || type === 'overwriteBlueprint'"
|
||||
:label="$t('g.overwrite')"
|
||||
severity="warn"
|
||||
icon="pi pi-save"
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-save" />
|
||||
{{ $t('g.overwrite') }}
|
||||
</Button>
|
||||
<template v-else-if="type === 'dirtyClose'">
|
||||
<Button
|
||||
:label="$t('g.no')"
|
||||
severity="secondary"
|
||||
icon="pi pi-times"
|
||||
@click="onDeny"
|
||||
/>
|
||||
<Button :label="$t('g.save')" icon="pi pi-save" @click="onConfirm" />
|
||||
<Button variant="secondary" @click="onDeny">
|
||||
<i class="pi pi-times" />
|
||||
{{ $t('g.no') }}
|
||||
</Button>
|
||||
<Button @click="onConfirm">
|
||||
<i class="pi pi-save" />
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-else-if="type === 'reinstall'"
|
||||
:label="$t('desktopMenu.reinstall')"
|
||||
severity="warn"
|
||||
icon="pi pi-eraser"
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-eraser" />
|
||||
{{ $t('desktopMenu.reinstall') }}
|
||||
</Button>
|
||||
<!-- Info - just show an OK button -->
|
||||
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
|
||||
{{ $t('g.ok') }}
|
||||
</Button>
|
||||
<!-- Invalid - just show a close button. -->
|
||||
<Button
|
||||
v-else
|
||||
:label="$t('g.close')"
|
||||
severity="primary"
|
||||
icon="pi pi-times"
|
||||
@click="onCancel"
|
||||
/>
|
||||
<Button v-else variant="primary" @click="onCancel">
|
||||
<i class="pi pi-times" />
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="title"
|
||||
:message="error.exceptionMessage"
|
||||
:text-class="'break-words max-w-[60vw]'"
|
||||
text-class="break-words max-w-[60vw]"
|
||||
/>
|
||||
<template v-if="error.extensionFile">
|
||||
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
|
||||
@@ -14,18 +14,16 @@
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button v-show="!reportOpen" variant="textonly" @click="showReport">
|
||||
{{ $t('g.showReport') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('g.showReport')"
|
||||
@click="showReport"
|
||||
/>
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('issueReport.helpFix')"
|
||||
variant="textonly"
|
||||
@click="showContactSupport"
|
||||
/>
|
||||
>
|
||||
{{ $t('issueReport.helpFix') }}
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
@@ -40,18 +38,15 @@
|
||||
:repo-owner="repoOwner"
|
||||
:repo-name="repoName"
|
||||
/>
|
||||
<Button
|
||||
v-if="reportOpen"
|
||||
:label="$t('g.copyToClipboard')"
|
||||
icon="pi pi-copy"
|
||||
@click="copyReportToClipboard"
|
||||
/>
|
||||
<Button v-if="reportOpen" @click="copyReportToClipboard">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copyToClipboard') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
@@ -60,6 +55,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -128,7 +124,7 @@ onMounted(async () => {
|
||||
reportContent.value = generateErrorReport({
|
||||
systemStats: systemStatsStore.systemStats!,
|
||||
serverLogs: logs,
|
||||
workflow: app.graph.serialize(),
|
||||
workflow: app.rootGraph.serialize(),
|
||||
exceptionType: error.exceptionType,
|
||||
exceptionMessage: error.exceptionMessage,
|
||||
traceback: error.traceback,
|
||||
|
||||
199
src/components/dialog/content/MissingCoreNodesMessage.test.ts
Normal file
199
src/components/dialog/content/MissingCoreNodesMessage.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn()
|
||||
}))
|
||||
|
||||
const createMockNode = (type: string, version?: string): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: 'comfy-core', ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
describe('MissingCoreNodesMessage', () => {
|
||||
const mockSystemStatsStore = {
|
||||
systemStats: null as { system?: { comfyui_version?: string } } | null,
|
||||
refetchSystemStats: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the mock store state
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.refetchSystemStats = vi.fn()
|
||||
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
|
||||
// The actual store has more properties, but we only need these for our tests.
|
||||
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(MissingCoreNodesMessage, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Message },
|
||||
mocks: {
|
||||
$t: (key: string, params?: { version?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
|
||||
'loadWorkflowWarning.outdatedVersionGeneric':
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
|
||||
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
missingCoreNodes: {},
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('does not render when there are no missing core nodes', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders message when there are missing core nodes', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current ComfyUI version when available', async () => {
|
||||
// Set systemStats directly (store auto-fetches with useAsyncState)
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: { comfyui_version: '1.0.0' }
|
||||
}
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for component to render
|
||||
await nextTick()
|
||||
|
||||
// No need to check if fetchSystemStats was called since useAsyncState auto-fetches
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays generic message when version is unavailable', async () => {
|
||||
// No systemStats set - version unavailable
|
||||
mockSystemStatsStore.systemStats = null
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for the async operations to complete
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups nodes by version and displays them', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('NodeA', '1.2.0'),
|
||||
createMockNode('NodeB', '1.2.0')
|
||||
],
|
||||
'1.3.0': [createMockNode('NodeC', '1.3.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Requires ComfyUI 1.3.0:')
|
||||
expect(text).toContain('NodeC')
|
||||
expect(text).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(text).toContain('NodeA, NodeB')
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.1.0': [createMockNode('Node1', '1.1.0')],
|
||||
'1.3.0': [createMockNode('Node3', '1.3.0')],
|
||||
'1.2.0': [createMockNode('Node2', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
const version13Index = text.indexOf('1.3.0')
|
||||
const version12Index = text.indexOf('1.2.0')
|
||||
const version11Index = text.indexOf('1.1.0')
|
||||
|
||||
expect(version13Index).toBeLessThan(version12Index)
|
||||
expect(version12Index).toBeLessThan(version11Index)
|
||||
})
|
||||
|
||||
it('removes duplicate node names within the same version', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('UniqueNode', '1.2.0')
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
// Should only appear once in the sorted list
|
||||
expect(text).toContain('DuplicateNode, UniqueNode')
|
||||
// Count occurrences of 'DuplicateNode' - should be only 1
|
||||
const matches = text.match(/DuplicateNode/g) || []
|
||||
expect(matches.length).toBe(1)
|
||||
})
|
||||
|
||||
it('handles nodes with missing version info', async () => {
|
||||
const missingCoreNodes = {
|
||||
'': [createMockNode('NoVersionNode')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
@@ -4,36 +4,27 @@
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
||||
>
|
||||
<IconTextButton
|
||||
:label="$t('missingNodes.cloud.learnMore')"
|
||||
type="transparent"
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
icon-position="left"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
:label="$t('missingNodes.cloud.gotIt')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="handleGotItClick"
|
||||
/>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<TextButton
|
||||
:label="$t('g.openManager')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
@click="openManager"
|
||||
/>
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
@@ -56,8 +47,7 @@
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{
|
||||
isCloud
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -51,8 +51,7 @@
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
variant="secondary"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
@@ -66,8 +65,7 @@
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
variant="secondary"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
@@ -82,8 +80,7 @@
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
variant="secondary"
|
||||
@click="showApiKeyForm = true"
|
||||
>
|
||||
<img
|
||||
@@ -142,12 +139,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import {
|
||||
|
||||
@@ -1,73 +1,277 @@
|
||||
<template>
|
||||
<div class="flex w-96 flex-col gap-10 p-2">
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
|
||||
<h1 class="my-0 text-2xl leading-normal font-medium">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
</h1>
|
||||
<p class="my-0 text-base">
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Balance Section -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="text-base text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<Button
|
||||
outlined
|
||||
severity="secondary"
|
||||
:label="$t('credits.topUp.seeDetails')"
|
||||
icon="pi pi-arrow-up-right"
|
||||
@click="handleSeeDetails"
|
||||
/>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount Input Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted"
|
||||
>{{ $t('credits.topUp.quickPurchase') }}:</span
|
||||
>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<CreditTopUpOption
|
||||
v-for="amount in amountOptions"
|
||||
:key="amount"
|
||||
:amount="amount"
|
||||
:preselected="amount === preselectedAmountOption"
|
||||
/>
|
||||
<!-- Warnings -->
|
||||
|
||||
<CreditTopUpOption :amount="100" :preselected="false" editable />
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!isValidAmount || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buyCredits') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
|
||||
const {
|
||||
isInsufficientCredits = false,
|
||||
amountOptions = [5, 10, 20, 50],
|
||||
preselectedAmountOption = 10
|
||||
} = defineProps<{
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
amountOptions?: number[]
|
||||
preselectedAmountOption?: number
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
|
||||
const handleSeeDetails = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
// Prevent double-clicks
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
await authActions.purchaseCredits(payAmount.value)
|
||||
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('credits.topUp.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,12 +7,9 @@
|
||||
<PasswordFields />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('userSettings.updatePassword')"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:loading="loading"
|
||||
/>
|
||||
<Button type="submit" class="mt-4 h-10 font-medium" :loading="loading">
|
||||
{{ $t('userSettings.updatePassword') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
@@ -20,10 +17,10 @@
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { updatePasswordSchema } from '@/schemas/signInSchema'
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const mountOption = (
|
||||
props?: Partial<{ credits: number; description: string; selected: boolean }>
|
||||
) =>
|
||||
mount(CreditTopUpOption, {
|
||||
props: {
|
||||
credits: 1000,
|
||||
description: '~100 videos*',
|
||||
selected: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('CreditTopUpOption', () => {
|
||||
it('renders credit amount and description', () => {
|
||||
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
expect(wrapper.text()).toContain('~500 videos*')
|
||||
})
|
||||
|
||||
it('applies unselected styling when not selected', () => {
|
||||
const wrapper = mountOption({ selected: false })
|
||||
expect(wrapper.find('div').classes()).toContain(
|
||||
'bg-component-node-disabled'
|
||||
)
|
||||
expect(wrapper.find('div').classes()).toContain('border-transparent')
|
||||
})
|
||||
|
||||
it('emits select event when clicked', async () => {
|
||||
const wrapper = mountOption()
|
||||
await wrapper.find('div').trigger('click')
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,81 +1,45 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<InputNumber
|
||||
v-if="editable"
|
||||
v-model="customAmount"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
show-buttons
|
||||
:allow-empty="false"
|
||||
:highlight-on-focus="true"
|
||||
pt:pc-input-text:root="w-24"
|
||||
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
|
||||
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
|
||||
/>
|
||||
<span v-else class="text-xl">{{ amount }}</span>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="[
|
||||
selected
|
||||
? 'bg-secondary-background border-2 border-border-default'
|
||||
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
|
||||
]"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<span class="text-base font-bold text-base-foreground">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressSpinner v-if="loading" class="h-8 w-8" />
|
||||
<Button
|
||||
v-else
|
||||
:severity="preselected ? 'primary' : 'secondary'"
|
||||
:outlined="!preselected"
|
||||
:label="$t('credits.topUp.buyNow')"
|
||||
@click="handleBuyNow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import type {
|
||||
InputNumberBlurEvent,
|
||||
InputNumberInputEvent
|
||||
} from 'primevue/inputnumber'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const {
|
||||
amount,
|
||||
preselected,
|
||||
editable = false
|
||||
} = defineProps<{
|
||||
amount: number
|
||||
preselected: boolean
|
||||
editable?: boolean
|
||||
const { credits, description, selected } = defineProps<{
|
||||
credits: number
|
||||
description: string
|
||||
selected: boolean
|
||||
}>()
|
||||
|
||||
const customAmount = ref(amount)
|
||||
const didClickBuyNow = ref(false)
|
||||
const loading = ref(false)
|
||||
defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
const creditAmount = editable ? customAmount.value : amount
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
|
||||
const { locale } = useI18n()
|
||||
|
||||
loading.value = true
|
||||
await authActions.purchaseCredits(creditAmount)
|
||||
loading.value = false
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (didClickBuyNow.value) {
|
||||
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
const formattedCredits = computed(() => {
|
||||
return formatCredits({
|
||||
value: credits,
|
||||
locale: locale.value,
|
||||
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<Button
|
||||
:label="$t('g.findIssues')"
|
||||
severity="secondary"
|
||||
icon="pi pi-github"
|
||||
@click="openGitHubIssues"
|
||||
/>
|
||||
<Button variant="secondary" @click="openGitHubIssues">
|
||||
<i class="pi pi-github" />
|
||||
{{ $t('g.findIssues') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -7,18 +7,26 @@
|
||||
pt:text="w-full"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div data-testid="current-user-indicator">
|
||||
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
|
||||
</div>
|
||||
<Button icon="pi pi-sign-out" text @click="logout" />
|
||||
<Button
|
||||
class="text-inherit"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('menuLabels.Sign Out')"
|
||||
@click="logout"
|
||||
>
|
||||
<i class="pi pi-sign-out" />
|
||||
</Button>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -23,24 +23,33 @@
|
||||
<template #body="slotProps">
|
||||
<div class="actions invisible flex flex-row">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
class="p-button-text"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.edit')"
|
||||
@click="editKeybinding(slotProps.data)"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
icon="pi pi-replay"
|
||||
class="p-button-text p-button-warn"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.reset')"
|
||||
:disabled="
|
||||
!keybindingStore.isCommandKeybindingModified(slotProps.data.id)
|
||||
"
|
||||
@click="resetKeybinding(slotProps.data)"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-replay" />
|
||||
</Button>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
class="p-button-text p-button-danger"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.delete')"
|
||||
:disabled="!slotProps.data.keybinding"
|
||||
@click="removeKeybinding(slotProps.data)"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -104,30 +113,31 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
:label="existingKeybindingOnCombo ? 'Overwrite' : 'Save'"
|
||||
:icon="existingKeybindingOnCombo ? 'pi pi-pencil' : 'pi pi-check'"
|
||||
:severity="existingKeybindingOnCombo ? 'warn' : undefined"
|
||||
:variant="existingKeybindingOnCombo ? 'destructive' : 'primary'"
|
||||
autofocus
|
||||
@click="saveKeybinding"
|
||||
/>
|
||||
>
|
||||
<i
|
||||
:class="existingKeybindingOnCombo ? 'pi pi-pencil' : 'pi pi-check'"
|
||||
/>
|
||||
{{ existingKeybindingOnCombo ? $t('g.overwrite') : $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Button
|
||||
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
|
||||
class="mt-4"
|
||||
:label="$t('g.resetAll')"
|
||||
icon="pi pi-replay"
|
||||
severity="danger"
|
||||
fluid
|
||||
text
|
||||
class="mt-4 w-full"
|
||||
variant="destructive-textonly"
|
||||
@click="resetAllKeybindings"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-replay" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</PanelTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterMatchMode } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Dialog from 'primevue/dialog'
|
||||
@@ -139,6 +149,7 @@ import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
@@ -16,10 +17,11 @@
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
:label="$t('credits.purchaseCredits')"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
/>
|
||||
>
|
||||
{{ $t('credits.purchaseCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<Skeleton
|
||||
@@ -32,25 +34,26 @@
|
||||
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
size="small"
|
||||
severity="secondary"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
:label="$t('credits.invoiceHistory')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-up-right"
|
||||
variant="muted-textonly"
|
||||
:loading="loading"
|
||||
@click="handleCreditsHistoryClick"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
@@ -85,34 +88,24 @@
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
:label="$t('credits.faqs')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
@click="handleFaqClick"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('credits.messageSupport')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-comments"
|
||||
@click="handleMessageSupport"
|
||||
/>
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
@@ -122,6 +115,7 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -138,7 +132,7 @@ interface CreditHistoryItemData {
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
@@ -193,9 +187,7 @@ const handleFaqClick = () => {
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
|
||||
includeLocale: true
|
||||
}),
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="permissions.canViewPendingInvites">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.pendingInvitesCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members Content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Table Header with Tab Buttons and Column Headers -->
|
||||
<div
|
||||
v-if="uiConfig.showMembersList"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'active'"
|
||||
>
|
||||
{{ $t('workspacePanel.members.tabs.active') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="
|
||||
activeView === 'pending' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'pending'"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Date column headers -->
|
||||
<template v-if="activeView === 'pending'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('inviteDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Active Members -->
|
||||
<template v-if="activeView === 'active'">
|
||||
<!-- Personal Workspace: show only current user -->
|
||||
<template v-if="isPersonalWorkspace">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ userDisplayName }}
|
||||
<span class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.roleOwner') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ userEmail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(member, index) in filteredMembers"
|
||||
:key="member.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="
|
||||
isCurrentUser(member) ? userPhotoUrl : undefined
|
||||
"
|
||||
:pt:icon:class="{
|
||||
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
|
||||
}"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span
|
||||
v-if="isCurrentUser(member)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ getRoleBadgeLabel(member.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn"
|
||||
class="text-sm text-muted-foreground text-right"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
</span>
|
||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
v-if="!isCurrentUser(member)"
|
||||
v-tooltip="{
|
||||
value: $t('g.moreOptions'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="showMemberMenu($event, member)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member actions menu (shared for all members) -->
|
||||
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.pendingGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Invite info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ getInviteInitial(invite.email) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ getInviteDisplayName(invite.email) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invite date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</span>
|
||||
<!-- Expiry date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</span>
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.copyLink'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('workspacePanel.members.actions.copyLink')"
|
||||
@click="handleCopyInviteLink(invite)"
|
||||
>
|
||||
<i class="icon-[lucide--link] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.revokeInvite'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="
|
||||
$t('workspacePanel.members.actions.revokeInvite')
|
||||
"
|
||||
@click="handleRevokeInvite(invite)"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredPendingInvites.length === 0"
|
||||
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Personal Workspace Message -->
|
||||
<div v-if="isPersonalWorkspace" class="flex items-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
|
||||
</p>
|
||||
<button
|
||||
class="underline bg-transparent border-none cursor-pointer"
|
||||
@click="handleCreateWorkspace"
|
||||
>
|
||||
{{ $t('workspacePanel.members.createNewWorkspace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
PendingInvite,
|
||||
WorkspaceMember
|
||||
} from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { d, t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
|
||||
const {
|
||||
showRemoveMemberDialog,
|
||||
showRevokeInviteDialog,
|
||||
showCreateWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
members,
|
||||
pendingInvites,
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<'active' | 'pending'>('active')
|
||||
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
const selectedMember = ref<WorkspaceMember | null>(null)
|
||||
|
||||
function getInviteDisplayName(email: string): string {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
|
||||
function getInviteInitial(email: string): string {
|
||||
return email.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
const memberMenuItems = computed(() => [
|
||||
{
|
||||
label: t('workspacePanel.members.actions.removeMember'),
|
||||
icon: 'pi pi-user-minus',
|
||||
command: () => {
|
||||
if (selectedMember.value) {
|
||||
handleRemoveMember(selectedMember.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function showMemberMenu(event: Event, member: WorkspaceMember) {
|
||||
selectedMember.value = member
|
||||
memberMenu.value?.toggle(event)
|
||||
}
|
||||
|
||||
function isCurrentUser(member: WorkspaceMember): boolean {
|
||||
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
|
||||
}
|
||||
|
||||
// All members sorted: owners first, current user second, then rest by join date
|
||||
const filteredMembers = computed(() => {
|
||||
let result = [...members.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(member) =>
|
||||
member.name.toLowerCase().includes(query) ||
|
||||
member.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
// Owners always come first
|
||||
if (a.role === 'owner' && b.role !== 'owner') return -1
|
||||
if (a.role !== 'owner' && b.role === 'owner') return 1
|
||||
|
||||
// Current user comes second (after owner)
|
||||
const aIsCurrentUser = isCurrentUser(a)
|
||||
const bIsCurrentUser = isCurrentUser(b)
|
||||
if (aIsCurrentUser && !bIsCurrentUser) return -1
|
||||
if (!aIsCurrentUser && bIsCurrentUser) return 1
|
||||
|
||||
// Then sort by join date
|
||||
const aValue = a.joinDate.getTime()
|
||||
const bValue = b.joinDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
|
||||
return role === 'owner'
|
||||
? t('workspaceSwitcher.roleOwner')
|
||||
: t('workspaceSwitcher.roleMember')
|
||||
}
|
||||
|
||||
const filteredPendingInvites = computed(() => {
|
||||
let result = [...pendingInvites.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter((invite) =>
|
||||
invite.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
|
||||
result.sort((a, b) => {
|
||||
const aDate = a[field]
|
||||
const bDate = b[field]
|
||||
if (!aDate || !bDate) return 0
|
||||
const aValue = aDate.getTime()
|
||||
const bValue = bDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
|
||||
async function handleCopyInviteLink(invite: PendingInvite) {
|
||||
try {
|
||||
await copyInviteLink(invite.id)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.copied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRevokeInvite(invite: PendingInvite) {
|
||||
showRevokeInviteDialog(invite.id)
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
showCreateWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleRemoveMember(member: WorkspaceMember) {
|
||||
showRemoveMemberDialog(member.id)
|
||||
}
|
||||
</script>
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
}))
|
||||
|
||||
describe('SettingItem', () => {
|
||||
const mountComponent = (props: any, options = {}): any => {
|
||||
const mountComponent = (props: Record<string, unknown>, options = {}) => {
|
||||
return mount(SettingItem, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
@@ -32,6 +32,7 @@ describe('SettingItem', () => {
|
||||
'i-material-symbols:experiment-outline': true
|
||||
}
|
||||
},
|
||||
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
|
||||
props,
|
||||
...options
|
||||
})
|
||||
@@ -48,8 +49,9 @@ describe('SettingItem', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Get the options property of the FormItem
|
||||
const options = wrapper.vm.formItem.options
|
||||
// Check the FormItem component's item prop for the options
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
const options = formItem.props('item').options
|
||||
expect(options).toEqual([
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
@@ -67,7 +69,8 @@ describe('SettingItem', () => {
|
||||
})
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
expect(wrapper.vm.formItem.tooltip).toBe(
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
expect(formItem.props('item').tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from 'primevue/button'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Column from 'primevue/column'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import DataTable from 'primevue/datatable'
|
||||
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
import UsageLogsTable from './UsageLogsTable.vue'
|
||||
@@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
|
||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
events: any[]
|
||||
events: Partial<AuditLog>[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
|
||||
@@ -78,9 +78,12 @@
|
||||
}
|
||||
}
|
||||
}"
|
||||
icon="pi pi-info-circle"
|
||||
class="p-button-text p-button-sm"
|
||||
/>
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('credits.additionalInfo')"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
</Button>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
@@ -89,13 +92,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
|
||||
@@ -44,11 +44,12 @@
|
||||
value: $t('userSettings.updatePassword'),
|
||||
showDelay: 300
|
||||
}"
|
||||
icon="pi pi-pen-to-square"
|
||||
severity="secondary"
|
||||
text
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
@click="dialogService.showUpdatePasswordDialog()"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-pen-to-square" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,21 +59,18 @@
|
||||
style="--pc-spinner-color: #000"
|
||||
/>
|
||||
<div v-else class="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
class="w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
<Button class="w-32" variant="secondary" @click="handleSignOut">
|
||||
<i class="pi pi-sign-out" />
|
||||
{{ $t('auth.signOut.signOut') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isApiKeyLogin"
|
||||
class="w-fit"
|
||||
variant="text"
|
||||
severity="danger"
|
||||
:label="$t('auth.deleteAccount.deleteAccount')"
|
||||
variant="destructive-textonly"
|
||||
@click="handleDeleteAccount"
|
||||
/>
|
||||
>
|
||||
{{ $t('auth.deleteAccount.deleteAccount') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,24 +82,25 @@
|
||||
|
||||
<Button
|
||||
class="w-52"
|
||||
severity="primary"
|
||||
variant="primary"
|
||||
:loading="loading"
|
||||
:label="$t('auth.login.signInOrSignUp')"
|
||||
icon="pi pi-user"
|
||||
@click="handleSignIn"
|
||||
/>
|
||||
>
|
||||
<i class="pi pi-user" />
|
||||
{{ $t('auth.login.signInOrSignUp') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
|
||||
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
238
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
238
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
})
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
useWorkspaceUI()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleEditWorkspace() {
|
||||
showEditWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return null
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : null
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
// Add edit option for owners
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: handleEditWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value
|
||||
? 'text-danger/50 cursor-not-allowed'
|
||||
: 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||
})
|
||||
} else if (action === 'leave') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: handleLeaveWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
@@ -63,7 +65,7 @@ describe('ApiKeyForm', () => {
|
||||
mockLoading.mockReset()
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
|
||||
return mount(ApiKeyForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
@@ -99,9 +101,10 @@ describe('ApiKeyForm', () => {
|
||||
)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const submitButton = wrapper
|
||||
.findAllComponents(Button)
|
||||
.find((btn) => btn.text() === 'Save')
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const submitButton = buttons.find(
|
||||
(btn) => btn.attributes('type') === 'submit'
|
||||
)
|
||||
expect(submitButton?.props('loading')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -67,10 +67,15 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<Button type="button" link @click="$emit('back')">
|
||||
<Button type="button" variant="textonly" @click="$emit('back')">
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button type="submit" :loading="loading" :disabled="loading">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -82,12 +87,12 @@
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
@@ -112,8 +112,10 @@ describe('SignInForm', () => {
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
@@ -138,7 +140,10 @@ describe('SignInForm', () => {
|
||||
|
||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Spy on handleForgotPassword
|
||||
const handleForgotPasswordSpy = vi.spyOn(
|
||||
@@ -161,7 +166,10 @@ describe('SignInForm', () => {
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Call onSubmit directly with valid data
|
||||
component.onSubmit({
|
||||
@@ -181,7 +189,10 @@ describe('SignInForm', () => {
|
||||
|
||||
it('does not emit submit event when form is invalid', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Call onSubmit with invalid form
|
||||
component.onSubmit({ valid: false, values: {} })
|
||||
@@ -254,12 +265,17 @@ describe('SignInForm', () => {
|
||||
describe('Focus Behavior', () => {
|
||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
@@ -273,12 +289,17 @@ describe('SignInForm', () => {
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with valid email
|
||||
await component.handleForgotPassword('test@example.com', true)
|
||||
|
||||
@@ -60,13 +60,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="h-8 w-8" />
|
||||
<ProgressSpinner v-if="loading" class="mx-auto h-8 w-8" />
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
:label="t('auth.login.loginButton')"
|
||||
class="mt-4 h-10 font-medium"
|
||||
/>
|
||||
:disabled="!$form.valid"
|
||||
>
|
||||
{{ t('auth.login.loginButton') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
@@ -74,7 +76,7 @@
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
@@ -82,6 +84,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { signInSchema } from '@/schemas/signInSchema'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
@@ -100,11 +103,11 @@ const emit = defineEmits<{
|
||||
|
||||
const emailInputId = 'comfy-org-sign-in-email'
|
||||
|
||||
const onSubmit = (event: FormSubmitEvent) => {
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignInData)
|
||||
}
|
||||
}
|
||||
}, 1_500)
|
||||
|
||||
const handleForgotPassword = async (
|
||||
email: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
class="flex flex-col gap-6"
|
||||
:resolver="zodResolver(signUpSchema)"
|
||||
@submit="onSubmit"
|
||||
@@ -28,11 +29,15 @@
|
||||
<PasswordFields />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="mx-auto h-8 w-8" />
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
:label="t('auth.signup.signUpButton')"
|
||||
class="mt-4 h-10 font-medium"
|
||||
/>
|
||||
:disabled="!$form.valid"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
@@ -40,24 +45,30 @@
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import PasswordFields from './PasswordFields.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData]
|
||||
}>()
|
||||
|
||||
const onSubmit = (event: FormSubmitEvent) => {
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignUpData)
|
||||
}
|
||||
}
|
||||
}, 1_500)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
@keydown.enter="isValidName && onCreate()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const workspaceName = ref('')
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const name = workspaceName.value.trim()
|
||||
// Call optional callback if provided
|
||||
await onConfirm?.(name)
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
// Create workspace and switch to it (triggers reload internally)
|
||||
await workspaceStore.createWorkspace(name)
|
||||
} catch (error) {
|
||||
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user