mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-20 23:13:40 +00:00
Compare commits
7 Commits
fix/linear
...
feat/ephem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a6f3c6b9 | ||
|
|
c4db198875 | ||
|
|
040e490f02 | ||
|
|
90c523b4a3 | ||
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 |
@@ -5,7 +5,6 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -42,7 +41,6 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -38,7 +38,6 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
const dialogs = page.getByRole('dialog')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
if ((await dialogs.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -44,16 +44,32 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
key: 'renderer-default',
|
||||
title: 'Default renderer dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-escape-hatch',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dialog migration regression net: the showConfirmDialog helper must open
|
||||
* its dialog through the Reka renderer with zeroed section padding (the
|
||||
* Confirm* sections carry their own). Catches accidental reverts of the
|
||||
* Phase 6 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
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 { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
|
||||
describe('showConfirmDialog Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
|
||||
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
|
||||
showConfirmDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('md')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the confirm section components and caller props', () => {
|
||||
showConfirmDialog({
|
||||
key: 'confirm-test',
|
||||
headerProps: { title: 'Title' },
|
||||
props: { promptText: 'Prompt' },
|
||||
footerProps: { confirmText: 'Delete' }
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('confirm-test')
|
||||
expect(args.headerComponent).toBe(ConfirmHeader)
|
||||
expect(args.component).toBe(ConfirmBody)
|
||||
expect(args.footerComponent).toBe(ConfirmFooter)
|
||||
expect(args.headerProps).toEqual({ title: 'Title' })
|
||||
expect(args.props).toEqual({ promptText: 'Prompt' })
|
||||
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
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 type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -23,11 +26,13 @@ export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'md',
|
||||
// Confirm sections carry their own padding — zero out the dialog
|
||||
// chrome padding, like the PrimeVue `pt` overrides did.
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0',
|
||||
footerClass: 'p-0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,7 +15,14 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,7 +28,26 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
interface UseDomClippingReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updateClipPath: (
|
||||
element: HTMLElement,
|
||||
canvasElement: HTMLCanvasElement,
|
||||
isSelected: boolean,
|
||||
selectedArea?: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useDomClipping(
|
||||
options: ClippingOptions = {}
|
||||
): UseDomClippingReturn {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { ComputedRef, CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -8,10 +8,23 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_MARGIN = 16
|
||||
|
||||
interface UseNodePreviewAndDragReturn {
|
||||
previewRef: Ref<HTMLElement | null>
|
||||
isHovered: Ref<boolean>
|
||||
isDragging: Ref<boolean>
|
||||
showPreview: ComputedRef<boolean>
|
||||
nodePreviewStyle: Ref<CSSProperties>
|
||||
sidebarLocation: ComputedRef<'left' | 'right'>
|
||||
handleMouseEnter: (e: MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
handleDragStart: (e: DragEvent) => void
|
||||
handleDragEnd: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
) {
|
||||
): UseNodePreviewAndDragReturn {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
|
||||
@@ -9,19 +9,13 @@ export const useQueueClearHistoryDialog = () => {
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: 'bg-transparent',
|
||||
style: 'padding: 0'
|
||||
}
|
||||
}
|
||||
// The content draws its own panel — neutralize the chrome box.
|
||||
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
type ResizeDirection =
|
||||
export type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -17,6 +17,17 @@ type ResizeDirection =
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
export interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
/** Minimum crop width/height in source image pixel space. */
|
||||
@@ -264,17 +275,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
|
||||
|
||||
const allResizeHandles = computed<ResizeHandle[]>(() => {
|
||||
|
||||
@@ -36,6 +36,15 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
options?.afterClose?.()
|
||||
},
|
||||
initialCategory
|
||||
},
|
||||
// The template browser is a wide layout. Without an explicit size the
|
||||
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the
|
||||
// filter bar so the Clear Filters button lands outside the viewport.
|
||||
// Size it like the other large dialogs (Settings/Manager).
|
||||
dialogComponentProps: {
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[80vh] rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
94
src/config/comfyApi.test.ts
Normal file
94
src/config/comfyApi.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
|
||||
describe('getComfyApiBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: '' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComfyPlatformBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = {
|
||||
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
|
||||
}
|
||||
expect(getComfyPlatformBaseUrl()).toBe(
|
||||
'https://my-ephem-platform.example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_platform_base_url: '' }
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatibility with comfyui servers that predate the override keys', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('falls back to build-time defaults when /features omits the URL keys', async () => {
|
||||
// An older comfyui server has /features but doesn't know about
|
||||
// comfy_api_base_url / comfy_platform_base_url yet.
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
supports_preview_metadata: true,
|
||||
max_upload_size: 104857600
|
||||
})
|
||||
} as Response)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
@@ -19,11 +18,14 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
: (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ??
|
||||
STAGING_PLATFORM_BASE_URL)
|
||||
|
||||
/**
|
||||
* Resolves the ComfyUI API base URL.
|
||||
*
|
||||
* The local server (any distribution) is authoritative:
|
||||
* whatever `/api/features` returns for `comfy_api_base_url` wins, falling back to the build-time default.
|
||||
* This lets a self-hosted ComfyUI - point its frontend at a different api host without rebuilding the frontend package.
|
||||
*/
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_API_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_api_base_url',
|
||||
@@ -31,11 +33,11 @@ export function getComfyApiBaseUrl(): string {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ComfyUI Platform base URL.
|
||||
* As with the api base, the server's `/api/features` (`comfy_platform_base_url`) overrides the build-time default.
|
||||
*/
|
||||
export function getComfyPlatformBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_PLATFORM_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
|
||||
46
src/config/firebase.test.ts
Normal file
46
src/config/firebase.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
import { getFirebaseConfig } from './firebase'
|
||||
|
||||
describe('getFirebaseConfig', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors a full server-provided firebase_config (cloud builds)', () => {
|
||||
const cloud = {
|
||||
apiKey: 'cloud-key',
|
||||
authDomain: 'cloud.example.com',
|
||||
projectId: 'some-cloud-project',
|
||||
storageBucket: 'cloud.appspot.com',
|
||||
messagingSenderId: '1',
|
||||
appId: '1:1:web:abc'
|
||||
}
|
||||
remoteConfig.value = { firebase_config: cloud }
|
||||
expect(getFirebaseConfig()).toEqual(cloud)
|
||||
})
|
||||
|
||||
it('uses the dev project for a staging-tier api base (staging or testenv)', () => {
|
||||
// No firebase_config from the server — the dev project is derived from the
|
||||
// api base, using the DEV config bundled in the frontend.
|
||||
remoteConfig.value = { comfy_api_base_url: 'https://stagingapi.comfy.org' }
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
remoteConfig.value = {
|
||||
comfy_api_base_url: 'https://pr-1-registry.testenvs.comfy.org'
|
||||
}
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
|
||||
it('falls back to the build-time config otherwise', () => {
|
||||
// The test build uses the non-prod config => dreamboothy-dev.
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
@@ -27,16 +26,28 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
|
||||
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
const STAGING_API_HOST = 'stagingapi.comfy.org'
|
||||
const TESTENV_HOST_SUFFIX = '.testenvs.comfy.org'
|
||||
|
||||
// staging + the ephemeral testenvs use the dev Firebase project (prod uses prod)
|
||||
function isStagingTierApiBase(apiBase: string | undefined): boolean {
|
||||
if (!apiBase) return false
|
||||
try {
|
||||
const host = new URL(apiBase).hostname
|
||||
return host === STAGING_API_HOST || host.endsWith(TESTENV_HOST_SUFFIX)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Firebase configuration for the current environment.
|
||||
* - Cloud builds use runtime configuration delivered via feature flags
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
* Firebase config for the current backend: the server's firebase_config (cloud builds),
|
||||
* else the bundled DEV_CONFIG when the api base is staging-tier, else the build-time default.
|
||||
*/
|
||||
export function getFirebaseConfig(): FirebaseOptions {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
const runtimeConfig = remoteConfig.value.firebase_config
|
||||
return runtimeConfig ?? BUILD_TIME_CONFIG
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (isStagingTierApiBase(remoteConfig.value.comfy_api_base_url))
|
||||
return DEV_CONFIG
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
@@ -154,8 +154,10 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
export const t: (typeof i18n.global)['t'] = i18n.global.t
|
||||
export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -3340,7 +3340,7 @@
|
||||
"mediaLabel": "{count} Media File | {count} Media Files",
|
||||
"modelsLabel": "{count} Model | {count} Models",
|
||||
"checkingAssets": "Checking media visibility…",
|
||||
"acknowledgeCheckbox": "I understand these media items will be published and made public",
|
||||
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
|
||||
"inLibrary": "In library",
|
||||
"comfyHubTitle": "Upload to ComfyHub",
|
||||
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
|
||||
|
||||
17
src/main.ts
17
src/main.ts
@@ -5,7 +5,6 @@ import { initializeApp } from 'firebase/app'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { createApp } from 'vue'
|
||||
@@ -31,15 +30,18 @@ import App from './App.vue'
|
||||
import './assets/css/style.css'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
/**
|
||||
* CRITICAL: Load remote config FIRST so window.__CONFIG__ is available for all modules during initialization.
|
||||
* The local /api/features endpoint is the source of truth for runtime config (api base, Firebase project, …).
|
||||
* Allows the server to dictate which backend the frontend talks to and which Firebase project it logs in against.
|
||||
* Must run before initializeApp() below so getFirebaseConfig() sees it.
|
||||
*/
|
||||
const isCloud = __DISTRIBUTION__ === 'cloud'
|
||||
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
|
||||
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
|
||||
|
||||
if (requiresRemoteConfigBootstrap) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
}
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
if (isCloud) {
|
||||
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
|
||||
@@ -133,7 +135,6 @@ app
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(ConfirmationService)
|
||||
.use(ToastService)
|
||||
.use(pinia)
|
||||
.use(i18n)
|
||||
|
||||
@@ -144,7 +144,6 @@ import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -158,7 +157,10 @@ import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
@@ -279,12 +281,15 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const displayImageDimensions = computed(() =>
|
||||
resolveDisplayImageDimensions(asset, imageDimensions.value)
|
||||
)
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
|
||||
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
if (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
|
||||
@@ -774,6 +774,10 @@ export function useMediaAssetActions() {
|
||||
onCancel: () => {
|
||||
resolve(false)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
|
||||
|
||||
// Contents bring their own width and padding — shrink-wrap the chrome and
|
||||
// zero the section padding (the PrimeVue `pt` overrides this replaces).
|
||||
const uploadDialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
contentClass: 'w-fit max-w-[calc(100vw-1rem)]',
|
||||
headerClass: 'py-0 pl-0',
|
||||
bodyClass: 'p-0 overflow-y-hidden'
|
||||
} as const
|
||||
|
||||
export function useModelUpload(
|
||||
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
|
||||
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
|
||||
@@ -31,12 +40,7 @@ export function useModelUpload(
|
||||
key: 'upload-model-upgrade',
|
||||
headerComponent: UploadModelUpgradeModalHeader,
|
||||
component: UploadModelUpgradeModal,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
})
|
||||
} else {
|
||||
dialogStore.showDialog({
|
||||
@@ -49,12 +53,7 @@ export function useModelUpload(
|
||||
await onUploadSuccess?.(result)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -417,4 +419,124 @@ describe('assetMetadataUtils', () => {
|
||||
expect(getAssetCardTitle(asset)).toBe('pretty.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetMetadataDimensions', () => {
|
||||
it('returns dimensions when width/height are positive integers', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
|
||||
expect(getAssetMetadataDimensions(asset)).toEqual({
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: 'NaN width', width: Number.NaN, height: 768 },
|
||||
{
|
||||
name: 'Infinity height',
|
||||
width: 1024,
|
||||
height: Number.POSITIVE_INFINITY
|
||||
},
|
||||
{ name: 'zero width', width: 0, height: 768 },
|
||||
{ name: 'negative height', width: 1024, height: -1 },
|
||||
{ name: 'fractional width', width: 1024.5, height: 768 },
|
||||
{ name: 'string width', width: '1024', height: 768 },
|
||||
{ name: 'missing width', width: undefined, height: 768 }
|
||||
])('returns undefined for invalid shape: $name', ({ width, height }) => {
|
||||
const asset = { ...mockAsset, metadata: { width, height } }
|
||||
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when metadata is absent', () => {
|
||||
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when asset itself is undefined', () => {
|
||||
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDisplayImageDimensions', () => {
|
||||
const rendered = { width: 512, height: 288 }
|
||||
|
||||
it('prefers server metadata dimensions over the rendered natural size', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 1920, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
|
||||
const asset = { ...mockAsset }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
|
||||
const fullResUrl =
|
||||
'http://localhost:8188/view?filename=output.png&type=output'
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: fullResUrl,
|
||||
preview_url: fullResUrl
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 0, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined when neither metadata nor a rendered size is available', () => {
|
||||
expect(
|
||||
resolveDisplayImageDimensions(mockAsset, undefined)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
|
||||
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
|
||||
rendered
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,6 +216,64 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
return getAssetDisplayFilename(asset)
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
|
||||
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
|
||||
* NaN, Infinity, 0, negatives, and fractional values.
|
||||
*/
|
||||
function isValidDimension(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original image dimensions from `asset.metadata.{width,height}`
|
||||
* when both pass shape validation, otherwise `undefined`. Callers should fall
|
||||
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
|
||||
* on runtimes that serve the original file but reports preview size on
|
||||
* runtimes that serve a downscaled preview.
|
||||
*/
|
||||
export function getAssetMetadataDimensions(
|
||||
asset: AssetItem | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const w = asset?.metadata?.width
|
||||
const h = asset?.metadata?.height
|
||||
if (isValidDimension(w) && isValidDimension(h)) {
|
||||
return { width: w, height: h }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the image dimensions an asset card should display.
|
||||
*
|
||||
* Prefers the server-provided original dimensions from
|
||||
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
|
||||
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
|
||||
* actually rendered — and only when that rendered image was the original file.
|
||||
*
|
||||
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
|
||||
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
|
||||
* preview's dimensions rather than the asset's. In that case this returns
|
||||
* `undefined` so the card shows no label rather than a wrong resolution.
|
||||
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
|
||||
* so the guard correctly passes through `renderedNaturalSize`.
|
||||
*/
|
||||
export function resolveDisplayImageDimensions(
|
||||
asset: AssetItem | undefined,
|
||||
renderedNaturalSize: ImageDimensions | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const fromMetadata = getAssetMetadataDimensions(asset)
|
||||
if (fromMetadata) return fromMetadata
|
||||
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
|
||||
return undefined
|
||||
return renderedNaturalSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
|
||||
@@ -49,13 +49,9 @@ export const useSubscriptionDialog = () => {
|
||||
),
|
||||
props: { onClose: hide },
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(360px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'bg-transparent border-none rounded-none shadow-none'
|
||||
},
|
||||
content: { class: '!p-0 bg-transparent border-none shadow-none' }
|
||||
}
|
||||
renderer: 'reka',
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -89,16 +85,14 @@ export const useSubscriptionDialog = () => {
|
||||
component,
|
||||
props: useWorkspaceVariant ? workspaceProps : personalProps,
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent h-full'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
|
||||
}
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// The pricing tables host a PrimeVue Popover teleported to body.
|
||||
// Reka's modal mode traps focus and disables body pointer-events,
|
||||
// making the popover unclickable. Mirrors Settings/Manager.
|
||||
modal: false,
|
||||
contentClass:
|
||||
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -122,16 +116,10 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(640px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
@@ -43,9 +43,10 @@ describe('refreshRemoteConfig', () => {
|
||||
|
||||
await refreshRemoteConfig({ useAuth: true })
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/features',
|
||||
expect.objectContaining({ cache: 'no-store' })
|
||||
)
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
expect(remoteConfig.value).toEqual(mockConfig)
|
||||
expect(window.__CONFIG__).toEqual(mockConfig)
|
||||
@@ -67,15 +68,38 @@ describe('refreshRemoteConfig', () => {
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/features',
|
||||
expect.objectContaining({ cache: 'no-store' })
|
||||
)
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
expect(remoteConfig.value).toEqual(mockConfig)
|
||||
expect(window.__CONFIG__).toEqual(mockConfig)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout', () => {
|
||||
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const init = vi.mocked(global.fetch).mock.calls[0][1]
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('falls back to empty config when the request aborts', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(
|
||||
new DOMException('Aborted', 'AbortError')
|
||||
)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(remoteConfig.value).toEqual({})
|
||||
expect(window.__CONFIG__).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('clears config on 401 response', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
remoteConfigState
|
||||
} from './remoteConfig'
|
||||
|
||||
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
|
||||
// A same-origin GET against the local comfyui server should resolve in well under a second;
|
||||
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
|
||||
const FEATURES_FETCH_TIMEOUT_MS = 5_000
|
||||
|
||||
interface RefreshRemoteConfigOptions {
|
||||
/**
|
||||
* Whether to use authenticated API (default: true).
|
||||
@@ -12,11 +17,14 @@ interface RefreshRemoteConfigOptions {
|
||||
useAuth?: boolean
|
||||
}
|
||||
|
||||
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
|
||||
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
|
||||
async function fetchRemoteConfig(
|
||||
useAuth: boolean,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
if (!useAuth) return fetch('/api/features', { cache: 'no-store', signal })
|
||||
|
||||
const { api } = await import('@/scripts/api')
|
||||
return api.fetchApi('/features', { cache: 'no-store' })
|
||||
return api.fetchApi('/features', { cache: 'no-store', signal })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +41,14 @@ export async function refreshRemoteConfig(
|
||||
): Promise<void> {
|
||||
const { useAuth = true } = options
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
FEATURES_FETCH_TIMEOUT_MS
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetchRemoteConfig(useAuth)
|
||||
const response = await fetchRemoteConfig(useAuth, controller.signal)
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json()
|
||||
@@ -59,5 +73,7 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = {}
|
||||
remoteConfig.value = {}
|
||||
remoteConfigState.value = 'error'
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,7 @@ export function useShareDialog() {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
|
||||
}
|
||||
}
|
||||
contentClass: 'sm:max-w-144 rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,11 +110,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve({ action: 'cancel' }),
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
|
||||
}
|
||||
}
|
||||
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface Member {
|
||||
email: string
|
||||
joined_at: string
|
||||
role: WorkspaceRole
|
||||
// True when this member is the workspace's original owner/creator
|
||||
// (member.id == workspace.created_by_user_id). Gates the creator-only
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
|
||||
@@ -113,7 +113,11 @@
|
||||
button-variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
v-if="
|
||||
showSubscribeAction &&
|
||||
!isPersonalWorkspace &&
|
||||
(!isCancelled || permissions.canManageSubscriptionLifecycle)
|
||||
"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
|
||||
@@ -128,9 +128,10 @@
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
<!-- Cancelled state: reactivation is original-owner-only. -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
v-if="permissions.canManageSubscriptionLifecycle"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
@@ -161,7 +162,7 @@
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan"
|
||||
v-if="!isFreeTierPlan && planMenuItems.length > 0"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -513,15 +514,23 @@ const subscriptionTierName = computed(() => {
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
}
|
||||
])
|
||||
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
|
||||
// and the "more options" button is hidden (see template).
|
||||
const planMenuItems = computed(() =>
|
||||
permissions.value.canManageSubscriptionLifecycle
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
|
||||
@@ -82,7 +82,8 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
name: 'Owner User',
|
||||
email: 'owner@example.com',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
@@ -153,6 +154,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useMembersPanel() {
|
||||
name: userDisplayName.value ?? '',
|
||||
email: userEmail.value ?? '',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
}))
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -2,15 +2,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||
value: null as WorkspaceWithRole | null
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
activeWorkspace: null as WorkspaceWithRole | null,
|
||||
isCurrentUserOriginalOwner: false,
|
||||
ensureMembersLoaded: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
return mockStore.activeWorkspace
|
||||
},
|
||||
get isCurrentUserOriginalOwner() {
|
||||
return mockStore.isCurrentUserOriginalOwner
|
||||
},
|
||||
ensureMembersLoaded: mockStore.ensureMembersLoaded
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -46,14 +52,20 @@ async function loadComposable() {
|
||||
return module.useWorkspaceUI()
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
mockStore.activeWorkspace = null
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
mockStore.ensureMembersLoaded.mockReset()
|
||||
}
|
||||
|
||||
describe('useWorkspaceUI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('when no active workspace', () => {
|
||||
@@ -71,7 +83,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('personal workspace', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = personalWorkspace
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
})
|
||||
|
||||
it('grants billing access but disables team management', async () => {
|
||||
@@ -119,7 +131,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as owner', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
})
|
||||
|
||||
it('grants full management permissions', async () => {
|
||||
@@ -159,7 +171,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as member', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamMemberWorkspace
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
})
|
||||
|
||||
it('restricts management actions while allowing leave', async () => {
|
||||
@@ -195,9 +207,60 @@ describe('useWorkspaceUI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Drives off the members-list self-row original-owner signal, surfaced by the
|
||||
// store getter `isCurrentUserOriginalOwner`.
|
||||
describe('subscription lifecycle (creator-only)', () => {
|
||||
it('grants lifecycle to the personal-workspace sole owner', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('grants lifecycle to a team owner who is the original owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = true
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed while the members list is still loading', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from members', async () => {
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates member loading to the store when a team workspace becomes active', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared instance', () => {
|
||||
it('returns the same composable state for multiple callers within a test', async () => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
const first = await loadComposable()
|
||||
const second = await loadComposable()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
@@ -14,6 +14,10 @@ interface WorkspacePermissions {
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
|
||||
// Any owner has `canManageSubscription` (manage payment, top-up, change
|
||||
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
@@ -34,7 +38,8 @@ interface WorkspaceUIConfig {
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
role: WorkspaceRole,
|
||||
isOriginalOwner: boolean
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
@@ -46,6 +51,8 @@ function getPermissions(
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: false,
|
||||
canManageSubscription: true,
|
||||
// Personal workspace is single-member: the user is the sole owner/creator.
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -60,6 +67,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: isOriginalOwner,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -74,6 +82,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
}
|
||||
@@ -145,8 +154,26 @@ function useWorkspaceUIInternal() {
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
// The original-owner signal lives on the members-list self-row, so a team
|
||||
// workspace's members must be loaded before its lifecycle gate can resolve.
|
||||
// The store dedupes in-flight/already-loaded requests and logs failures;
|
||||
// until members arrive the getter fails closed.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
() => {
|
||||
if (store.activeWorkspace?.type === 'team') {
|
||||
void store.ensureMembersLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
getPermissions(
|
||||
workspaceType.value,
|
||||
workspaceRole.value,
|
||||
store.isCurrentUserOriginalOwner
|
||||
)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
|
||||
@@ -29,6 +29,15 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
// Mock current user (drives the original-owner self-row match by email)
|
||||
const mockCurrentUser = vi.hoisted(() => ({
|
||||
userEmail: { value: null as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
|
||||
}))
|
||||
|
||||
// Mock workspaceApi
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -122,6 +131,7 @@ describe('useTeamWorkspaceStore', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
sessionStorage.clear()
|
||||
mockCurrentUser.userEmail.value = null
|
||||
|
||||
// Reset workspaceAuthStore mock state
|
||||
mockWorkspaceAuthStore.currentWorkspace = null
|
||||
@@ -680,6 +690,193 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureMembersLoaded', () => {
|
||||
const memberRow = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
function mockMembersResponse() {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [memberRow],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
async function activateTeamWorkspace() {
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
return store
|
||||
}
|
||||
|
||||
it('loads members for a team workspace that is not yet loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
expect(store.members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not load members again once loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls into a single request', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await Promise.all([
|
||||
store.ensureMembersLoaded(),
|
||||
store.ensureMembersLoaded()
|
||||
])
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a failed request and retries on the next call', async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
expect(store.members).toHaveLength(0)
|
||||
|
||||
mockMembersResponse()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
|
||||
expect(store.members).toHaveLength(1)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCurrentUserOriginalOwner', () => {
|
||||
async function loadTeamWithMembers(
|
||||
members: Array<{
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joined_at: string
|
||||
is_original_owner?: boolean
|
||||
}>
|
||||
) {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
await store.fetchMembers()
|
||||
return store
|
||||
}
|
||||
|
||||
const ownerSelf = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z',
|
||||
role: 'owner' as const,
|
||||
is_original_owner: true
|
||||
}
|
||||
const promotedSelf = { ...ownerSelf, is_original_owner: false }
|
||||
|
||||
it('is true when the self-row is the original owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the self-row by email case-insensitively', async () => {
|
||||
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the self-row is a promoted (non-creator) owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([promotedSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the self-row omits is_original_owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
|
||||
const store = await loadTeamWithMembers([selfWithoutFlag])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when no member row matches the current user', async () => {
|
||||
mockCurrentUser.userEmail.value = 'someone-else@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when members are not loaded', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the current user email is unknown', async () => {
|
||||
mockCurrentUser.userEmail.value = null
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('recomputes reactively when the self-row arrives after an empty read', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [ownerSelf],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
|
||||
await store.fetchMembers()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite actions', () => {
|
||||
it('fetchPendingInvites updates active workspace invites', async () => {
|
||||
const mockInvites = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
@@ -21,6 +22,7 @@ export interface WorkspaceMember {
|
||||
email: string
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
isOriginalOwner: boolean
|
||||
}
|
||||
|
||||
export interface PendingInvite {
|
||||
@@ -49,7 +51,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role
|
||||
role: member.role,
|
||||
isOriginalOwner: member.is_original_owner ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +149,18 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
() => activeWorkspace.value?.members ?? []
|
||||
)
|
||||
|
||||
// True when the current user is the active workspace's original owner,
|
||||
// resolved from the self-row of the loaded members list. Matches by email
|
||||
// (the stable current-user join key; member.id is a cloud user id, not the
|
||||
// Firebase uid). Fails closed when members are not loaded or no self-row
|
||||
// matches, so lifecycle gating stays hidden until the real signal arrives.
|
||||
const isCurrentUserOriginalOwner = computed(() => {
|
||||
const email = useCurrentUser().userEmail.value?.toLowerCase()
|
||||
if (!email) return false
|
||||
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
|
||||
return selfRow?.isOriginalOwner ?? false
|
||||
})
|
||||
|
||||
const pendingInvites = computed<PendingInvite[]>(
|
||||
() => activeWorkspace.value?.pendingInvites ?? []
|
||||
)
|
||||
@@ -507,6 +522,36 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
return members
|
||||
}
|
||||
|
||||
// Tracks which team workspaces have already loaded their members so the
|
||||
// lifecycle gate resolves without redundant or duplicate fetches.
|
||||
const loadedMemberWorkspaceIds = new Set<string>()
|
||||
let inFlightMembersWorkspaceId: string | null = null
|
||||
|
||||
/**
|
||||
* Load the active team workspace's members once. No-ops for personal or
|
||||
* already-loaded workspaces and dedupes concurrent calls. A failed request is
|
||||
* logged and leaves the workspace unloaded so a later call retries.
|
||||
*/
|
||||
async function ensureMembersLoaded(): Promise<void> {
|
||||
const workspaceId = activeWorkspaceId.value
|
||||
if (!workspaceId) return
|
||||
if (activeWorkspace.value?.type === 'personal') return
|
||||
if (loadedMemberWorkspaceIds.has(workspaceId)) return
|
||||
if (inFlightMembersWorkspaceId === workspaceId) return
|
||||
|
||||
inFlightMembersWorkspaceId = workspaceId
|
||||
try {
|
||||
await fetchMembers()
|
||||
loadedMemberWorkspaceIds.add(workspaceId)
|
||||
} catch (e) {
|
||||
console.error('Failed to load workspace members', e)
|
||||
} finally {
|
||||
if (inFlightMembersWorkspaceId === workspaceId) {
|
||||
inFlightMembersWorkspaceId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the current workspace.
|
||||
*/
|
||||
@@ -652,6 +697,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
members,
|
||||
isCurrentUserOriginalOwner,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
@@ -675,6 +721,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Member Actions
|
||||
fetchMembers,
|
||||
ensureMembersLoaded,
|
||||
removeMember,
|
||||
|
||||
// Invite Actions
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { once } from 'es-toolkit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
const API_BASE_URL = 'https://api.comfy.org'
|
||||
|
||||
const registryApiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
paramsSerializer: {
|
||||
// Disables PHP-style notation (e.g. param[]=value) in favor of repeated params (e.g. param=value1¶m=value2)
|
||||
indexes: null
|
||||
}
|
||||
})
|
||||
// Lazy + memoized so the base URL is read once - after main.ts's top-level `await refreshRemoteConfig()`
|
||||
// populates remoteConfig - and then frozen for the lifetime of the app.
|
||||
const getRegistryClient = once(() =>
|
||||
axios.create({
|
||||
baseURL: getComfyApiBaseUrl(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
paramsSerializer: {
|
||||
// Disables PHP-style notation (e.g. param[]=value) in favor of repeated params (e.g. param=value1¶m=value2)
|
||||
indexes: null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Service for interacting with the Comfy Registry API
|
||||
@@ -118,7 +122,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
getRegistryClient().get<
|
||||
operations['ListComfyNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, {
|
||||
params: queryParams,
|
||||
@@ -142,7 +146,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
getRegistryClient().get<
|
||||
operations['searchNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, { params, signal }),
|
||||
errorContext
|
||||
@@ -164,7 +168,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Publisher']>(endpoint, {
|
||||
getRegistryClient().get<components['schemas']['Publisher']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
@@ -190,7 +194,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node'][]>(endpoint, {
|
||||
getRegistryClient().get<components['schemas']['Node'][]>(endpoint, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
@@ -217,10 +221,14 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.post<components['schemas']['Node']>(endpoint, null, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
getRegistryClient().post<components['schemas']['Node']>(
|
||||
endpoint,
|
||||
null,
|
||||
{
|
||||
params,
|
||||
signal
|
||||
}
|
||||
),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
@@ -238,7 +246,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
getRegistryClient().get<
|
||||
operations['listAllNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, { params, signal }),
|
||||
errorContext
|
||||
@@ -262,7 +270,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['NodeVersion'][]>(
|
||||
getRegistryClient().get<components['schemas']['NodeVersion'][]>(
|
||||
endpoint,
|
||||
{ params, signal }
|
||||
),
|
||||
@@ -288,9 +296,12 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['NodeVersion']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
getRegistryClient().get<components['schemas']['NodeVersion']>(
|
||||
endpoint,
|
||||
{
|
||||
signal
|
||||
}
|
||||
),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
@@ -311,7 +322,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node']>(endpoint, {
|
||||
getRegistryClient().get<components['schemas']['Node']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
@@ -352,7 +363,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node']>(endpoint, {
|
||||
getRegistryClient().get<components['schemas']['Node']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
@@ -399,7 +410,7 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.post<
|
||||
getRegistryClient().post<
|
||||
components['schemas']['BulkNodeVersionsResponse']
|
||||
>(endpoint, requestBody, {
|
||||
signal
|
||||
|
||||
@@ -79,4 +79,56 @@ describe('dialogService Reka renderer opt-in', () => {
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
|
||||
it("showTopUpCreditsDialog() sets renderer 'reka' with a transparent shrink-wrapped chrome", async () => {
|
||||
await useDialogService().showTopUpCreditsDialog()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('bg-transparent')
|
||||
})
|
||||
|
||||
it("showLayoutDialog() defaults to renderer 'reka' headless without pt", () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showLayoutDialog({
|
||||
key: 'layout-test',
|
||||
component: Component,
|
||||
props: {}
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('showLayoutDialog() lets callers override the defaults', () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showLayoutDialog({
|
||||
key: 'layout-override-test',
|
||||
component: Component,
|
||||
props: {},
|
||||
dialogComponentProps: { closable: false, contentClass: 'w-170' }
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.closable).toBe(false)
|
||||
expect(args.dialogComponentProps.contentClass).toBe('w-170')
|
||||
})
|
||||
|
||||
it("showSmallLayoutDialog() sets renderer 'reka' with zeroed section padding", () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showSmallLayoutDialog({
|
||||
key: 'small-layout-test',
|
||||
component: Component
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0 overflow-y-hidden')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,20 @@ const lazyCloudNotificationContent = () =>
|
||||
const lazyPublishDialog = () =>
|
||||
import('@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue')
|
||||
|
||||
/**
|
||||
* Shrink-wrap the Reka DialogContent around the content's intrinsic width,
|
||||
* like the auto-sized PrimeVue root it replaces.
|
||||
*/
|
||||
const HUG_CONTENT_CLASS =
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)]'
|
||||
|
||||
/**
|
||||
* Reka chrome for headless dialogs whose content draws its own panel
|
||||
* (background/border/rounding) — neutralize the DialogContent box and
|
||||
* shrink-wrap it around the content.
|
||||
*/
|
||||
const SELF_STYLED_PANEL_CONTENT_CLASS = `${HUG_CONTENT_CLASS} border-none bg-transparent shadow-none`
|
||||
|
||||
export type ConfirmationDialogType =
|
||||
| 'default'
|
||||
| 'overwrite'
|
||||
@@ -199,6 +213,8 @@ export const useDialogService = () => {
|
||||
},
|
||||
headerComponent: ComfyOrgHeader,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: false,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -222,6 +238,10 @@ export const useDialogService = () => {
|
||||
onSuccess: () => resolve(true)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
// SignInContent is a fixed w-96 — size 'sm' (max-w-sm) leaves only
|
||||
// 352px after the body padding; hug the intrinsic width instead.
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: true,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -327,12 +347,9 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -351,6 +368,10 @@ export const useDialogService = () => {
|
||||
props: {
|
||||
onSuccess: () =>
|
||||
dialogStore.closeDialog({ key: 'global-update-password' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -380,20 +401,10 @@ export const useDialogService = () => {
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
}) {
|
||||
const layoutDefaultProps: DialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0! hidden'
|
||||
},
|
||||
content: {
|
||||
class: 'p-0! m-0!'
|
||||
}
|
||||
}
|
||||
closable: true
|
||||
}
|
||||
|
||||
return dialogStore.showDialog({
|
||||
@@ -415,18 +426,15 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
...rest,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
closable: true,
|
||||
pt: {
|
||||
root: { class: 'bg-base-background border-border-default' },
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!p-0 overflow-y-hidden' },
|
||||
footer: { class: '!p-0' },
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
|
||||
}
|
||||
}
|
||||
},
|
||||
// Contents bring their own width and separators — shrink-wrap the
|
||||
// chrome and zero the section padding.
|
||||
contentClass:
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-border-default',
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0 overflow-y-hidden',
|
||||
footerClass: 'p-0',
|
||||
...callerProps
|
||||
}
|
||||
})
|
||||
@@ -446,13 +454,10 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogPt = {
|
||||
const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
@@ -465,7 +470,7 @@ export const useDialogService = () => {
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,7 +484,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -498,7 +503,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -509,7 +514,7 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -520,7 +525,7 @@ export const useDialogService = () => {
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -532,7 +537,7 @@ export const useDialogService = () => {
|
||||
key: 'remove-member',
|
||||
component,
|
||||
props: { memberId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -543,7 +548,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -555,7 +560,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -567,7 +572,7 @@ export const useDialogService = () => {
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
props: { inviteId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -597,7 +602,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -612,9 +617,8 @@ export const useDialogService = () => {
|
||||
props: {},
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
pt: {
|
||||
root: { class: 'w-170 max-h-[85vh]' }
|
||||
},
|
||||
contentClass:
|
||||
'w-170 max-w-[calc(100vw-1rem)] sm:max-w-[42.5rem] rounded-2xl overflow-hidden',
|
||||
onClose: () => resolve()
|
||||
}
|
||||
})
|
||||
@@ -628,12 +632,13 @@ export const useDialogService = () => {
|
||||
key,
|
||||
component: ComfyHubPublishDialog,
|
||||
props: {
|
||||
onClose: () => dialogStore.closeDialog({ key })
|
||||
onClose: () => dialogStore.closeDialog({ key }),
|
||||
// Falls through to the BaseModalLayout root — keeps the e2e
|
||||
// publish-dialog selector working without the PrimeVue pt hook.
|
||||
'data-testid': 'publish-dialog'
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: { 'data-testid': 'publish-dialog' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ type DialogPosition =
|
||||
| 'bottomright'
|
||||
|
||||
/**
|
||||
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
|
||||
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
|
||||
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
|
||||
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
|
||||
* Selects the dialog renderer used by `GlobalDialog`. `'reka'` (the default)
|
||||
* renders the Reka-UI primitive set under `src/components/ui/dialog/`.
|
||||
* `'primevue'` is the legacy PrimeVue `Dialog` escape hatch, kept only until
|
||||
* the branch is deleted in the Phase 6 cleanup (FE-578).
|
||||
*/
|
||||
type DialogRenderer = 'primevue' | 'reka'
|
||||
|
||||
@@ -201,6 +201,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
closable: true,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
renderer: 'reka' as DialogRenderer,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
onMaximize: () => {
|
||||
|
||||
@@ -88,10 +88,9 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
|
||||
|
||||
const bottomLeftRef = useTemplateRef<HTMLDivElement>('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef<HTMLDivElement>('bottomRightRef')
|
||||
const linearWorkflowRef =
|
||||
useTemplateRef<InstanceType<typeof LinearControls>>('linearWorkflowRef')
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
|
||||
function dragDrop(e: DragEvent) {
|
||||
const { dataTransfer } = e
|
||||
|
||||
Reference in New Issue
Block a user