mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-28 16:27:32 +00:00
Compare commits
3 Commits
main
...
config/vit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc610390c2 | ||
|
|
21138e8e73 | ||
|
|
ae8448ed0c |
13
.github/workflows/ci-tests-unit.yaml
vendored
13
.github/workflows/ci-tests-unit.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
# Description: Unit and component testing with Vitest
|
||||
# Description: Unit and component testing with Vitest + coverage reporting
|
||||
name: 'CI: Tests Unit'
|
||||
|
||||
on:
|
||||
@@ -23,5 +23,12 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCompare",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "a_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "b_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCompare"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -19,12 +19,10 @@ import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
@@ -57,7 +55,6 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
@@ -81,11 +78,6 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
}
|
||||
|
||||
get workflowsTab() {
|
||||
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
|
||||
return this._workflowsTab
|
||||
@@ -200,7 +192,6 @@ export class ComfyPage {
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
@@ -247,7 +238,6 @@ export class ComfyPage {
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
|
||||
@@ -168,32 +168,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,29 +142,6 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** The builder footer nav containing save/navigation buttons. */
|
||||
private get builderFooterNav(): Locator {
|
||||
return this.page
|
||||
.getByRole('button', { name: 'Exit app builder' })
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
/** Get a button in the builder footer by its accessible name. */
|
||||
getFooterButton(name: string | RegExp): Locator {
|
||||
return this.builderFooterNav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** Click the save/save-as button in the builder footer. */
|
||||
async clickSave() {
|
||||
await this.getFooterButton(/^Save/).first().click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** The "Opens as" popover tab above the builder footer. */
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,7 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
zoomOutAction: 'zoom-out-action',
|
||||
zoomToFitAction: 'zoom-to-fit-action',
|
||||
zoomPercentageInput: 'zoom-percentage-input'
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
|
||||
},
|
||||
dialogs: {
|
||||
settings: 'settings-dialog',
|
||||
@@ -79,8 +74,7 @@ export const TestIds = {
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async function selectOutputNode(comfyPage: ComfyPage) {
|
||||
const { page } = comfyPage
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
|
||||
const { page } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
* Loads the default workflow, optionally transforms it (e.g. convert a node
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
await selectInputWidget(comfyPage, inputNode)
|
||||
|
||||
await appMode.goToOutputs()
|
||||
await selectOutputNode(comfyPage)
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
})
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
@@ -1,11 +1,89 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
// The save-as dialog should appear with filename input and view type selection
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(dialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(dialog.getByText('Save as')).toBeVisible()
|
||||
|
||||
// View type radio group should be present
|
||||
const radioGroup = dialog.getByRole('radiogroup')
|
||||
await expect(radioGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save as dialog allows entering filename and saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const workflowName = `${Date.now()} builder-save-test`
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill(workflowName)
|
||||
|
||||
// Save button should be enabled now
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeEnabled()
|
||||
await saveButton.click()
|
||||
|
||||
// Success dialog should appear
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
|
||||
test('Save as dialog disables save when filename is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Clear the filename input
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill('')
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Builder step navigation works correctly', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Should start at outputs (we ended there in setup)
|
||||
// Navigate to inputs
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Back button should be disabled on first step
|
||||
const backButton = appMode.getFooterButton('Back')
|
||||
await expect(backButton).toBeDisabled()
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = appMode.getFooterButton('Next')
|
||||
await expect(nextButton).toBeEnabled()
|
||||
|
||||
// Navigate forward
|
||||
await appMode.next()
|
||||
|
||||
// Back button should now be enabled
|
||||
await expect(backButton).toBeEnabled()
|
||||
|
||||
// Navigate to preview (last step)
|
||||
await appMode.next()
|
||||
|
||||
// Next button should be disabled on last step
|
||||
await expect(nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Verify builder toolbar is visible
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
// Press Escape
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Builder toolbar should be gone
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
await appMode.exitBuilder()
|
||||
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-direct-save`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now click save again — should save directly
|
||||
await appMode.clickSave()
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Split button chevron opens save-as for saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-split-btn`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click the chevron dropdown trigger
|
||||
const chevronButton = appMode.getFooterButton('Save as')
|
||||
await chevronButton.click()
|
||||
|
||||
// "Save as" menu item should appear
|
||||
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 })
|
||||
await menuItem.click()
|
||||
|
||||
// Save-as dialog should appear
|
||||
const newSaveAsDialog = page.getByRole('dialog')
|
||||
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
// Without selecting any outputs, click the save button
|
||||
// It should trigger the connect-output popover
|
||||
await appMode.clickSave()
|
||||
|
||||
// The popover should show a message about connecting outputs
|
||||
await expect(
|
||||
page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// App should be selected by default
|
||||
const appRadio = dialog.getByRole('radio', { name: /App/ })
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Click Node graph option
|
||||
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
test.describe(
|
||||
'Change Tracker - isLoadingGraph guard',
|
||||
{ tag: '@workflow' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
|
||||
// Register an extension that forces checkState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestCheckStateDuringLoad',
|
||||
afterConfigureGraph() {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore)
|
||||
.workflow.activeWorkflow
|
||||
if (!workflow) throw new Error('No workflow found')
|
||||
// Bypass the guard to reproduce the corruption bug:
|
||||
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
|
||||
|
||||
// Simulate the user clicking during graph loading
|
||||
workflow.changeTracker.checkState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Create tab 1: blank workflow (0 nodes)
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to tab 0 (workflow-a).
|
||||
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// switch to blank tab and back to verify no corruption
|
||||
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
||||
await tab1.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// switch again and verify no corruption
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function createTestImageDataUrl(label: string, color: string): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">` +
|
||||
`<rect width="200" height="200" fill="${color}"/>` +
|
||||
`<text x="50%" y="50%" fill="white" font-size="24" ` +
|
||||
`text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node).toContainText('No images to compare')
|
||||
await expect(node.locator('img')).toHaveCount(0)
|
||||
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(beforeImg).toBeVisible()
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,92 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Mask Editor', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB |
@@ -1,92 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Painter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Renders canvas and controls',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
await expect(painterWidget).toBeVisible()
|
||||
|
||||
await expect(painterWidget.locator('canvas')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Brush')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Eraser')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Clear')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.locator('input[type="color"]').first()
|
||||
).toBeVisible()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-default-state.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drawing a stroke changes the canvas',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const isEmptyBefore = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
expect(isEmptyBefore).toBe(true)
|
||||
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width * 0.3,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width * 0.7,
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const hasContent = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasContent).toBe(true)
|
||||
}).toPass()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,30 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Assets sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
|
||||
await tab.importedTab.click()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Zoom Controls', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
|
||||
})
|
||||
|
||||
test('Default zoom is 100% and node has a size', async ({ comfyPage }) => {
|
||||
const nodeSize = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.nodes[0].size
|
||||
)
|
||||
expect(nodeSize[0]).toBeGreaterThan(0)
|
||||
expect(nodeSize[1]).toBeGreaterThan(0)
|
||||
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await expect(zoomButton).toContainText('100%')
|
||||
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
expect(scale).toBeCloseTo(1.0, 1)
|
||||
})
|
||||
|
||||
test('Zoom to fit reduces percentage', async ({ comfyPage }) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
|
||||
await expect(zoomToFit).toBeVisible()
|
||||
await zoomToFit.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeLessThan(1.0)
|
||||
|
||||
await expect(zoomButton).not.toContainText('100%')
|
||||
})
|
||||
|
||||
test('Zoom out reduces percentage', async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
await zoomOut.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await zoomOut.click()
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeCloseTo(0.1, 1)
|
||||
|
||||
await expect(zoomButton).toContainText('10%')
|
||||
})
|
||||
|
||||
test('Manual percentage entry allows zoom in and zoom out', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const input = comfyPage.page
|
||||
.getByTestId(TestIds.canvas.zoomPercentageInput)
|
||||
.locator('input')
|
||||
await input.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await input.pressSequentially('100')
|
||||
await input.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeCloseTo(1.0, 1)
|
||||
|
||||
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
|
||||
await zoomIn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfterZoomIn = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfterZoomIn).toBeGreaterThan(1.0)
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
await zoomOut.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfterZoomOut = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn)
|
||||
})
|
||||
|
||||
test('Clicking zoom button toggles zoom controls visibility', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
|
||||
await expect(zoomToFit).toBeVisible()
|
||||
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(zoomToFit).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.8",
|
||||
"version": "1.43.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -45,6 +45,7 @@
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
--text-3xs: 0.5625rem;
|
||||
--text-3xs--line-height: calc(1 / 0.5625);
|
||||
--text-xxxs: 0.5625rem;
|
||||
--text-xxxs--line-height: calc(1 / 0.5625);
|
||||
|
||||
/* Font Families */
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
@@ -230,7 +230,6 @@
|
||||
--interface-builder-mode-background: var(--color-ocean-300);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
--interface-builder-mode-footer-background: var(--color-ocean-900);
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
@@ -374,7 +373,6 @@
|
||||
--interface-builder-mode-background: var(--color-ocean-900);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
--interface-builder-mode-footer-background: var(--color-ocean-900);
|
||||
|
||||
--nav-background: var(--color-charcoal-800);
|
||||
|
||||
@@ -518,9 +516,6 @@
|
||||
--color-interface-builder-mode-button-foreground: var(
|
||||
--interface-builder-mode-button-foreground
|
||||
);
|
||||
--color-interface-builder-mode-footer-background: var(
|
||||
--interface-builder-mode-footer-background
|
||||
);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
|
||||
@@ -16,7 +16,6 @@ export type {
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
AssetDownloadResponse,
|
||||
AssetInfo,
|
||||
AssetMetadataResponse,
|
||||
AssetTagHistogramResponse,
|
||||
AssetUpdated,
|
||||
@@ -39,11 +38,6 @@ export type {
|
||||
CheckAssetByHashError,
|
||||
CheckAssetByHashErrors,
|
||||
CheckAssetByHashResponses,
|
||||
CheckHubUsernameData,
|
||||
CheckHubUsernameError,
|
||||
CheckHubUsernameErrors,
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClaimInviteCodeData,
|
||||
ClaimInviteCodeError,
|
||||
ClaimInviteCodeErrors,
|
||||
@@ -68,19 +62,7 @@ export type {
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
CreateHubAssetUploadUrlResponse,
|
||||
CreateHubAssetUploadUrlResponses,
|
||||
CreateHubProfileData,
|
||||
CreateHubProfileError,
|
||||
CreateHubProfileErrors,
|
||||
CreateHubProfileRequest,
|
||||
CreateHubProfileResponse,
|
||||
CreateHubProfileResponses,
|
||||
CreateInviteRequest,
|
||||
CreateSecretData,
|
||||
CreateSecretError,
|
||||
@@ -129,11 +111,6 @@ export type {
|
||||
DeleteAssetErrors,
|
||||
DeleteAssetResponse,
|
||||
DeleteAssetResponses,
|
||||
DeleteHubWorkflowData,
|
||||
DeleteHubWorkflowError,
|
||||
DeleteHubWorkflowErrors,
|
||||
DeleteHubWorkflowResponse,
|
||||
DeleteHubWorkflowResponses,
|
||||
DeleteSecretData,
|
||||
DeleteSecretError,
|
||||
DeleteSecretErrors,
|
||||
@@ -235,16 +212,6 @@ export type {
|
||||
GetGlobalSubgraphsErrors,
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHubProfileByUsernameData,
|
||||
GetHubProfileByUsernameError,
|
||||
GetHubProfileByUsernameErrors,
|
||||
GetHubProfileByUsernameResponse,
|
||||
GetHubProfileByUsernameResponses,
|
||||
GetHubWorkflowData,
|
||||
GetHubWorkflowError,
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetInviteCodeStatusData,
|
||||
GetInviteCodeStatusError,
|
||||
GetInviteCodeStatusErrors,
|
||||
@@ -283,21 +250,11 @@ export type {
|
||||
GetModelsInFolderErrors,
|
||||
GetModelsInFolderResponse,
|
||||
GetModelsInFolderResponses,
|
||||
GetMyHubProfileData,
|
||||
GetMyHubProfileError,
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPublishedWorkflowData,
|
||||
GetPublishedWorkflowError,
|
||||
GetPublishedWorkflowErrors,
|
||||
GetPublishedWorkflowResponse,
|
||||
GetPublishedWorkflowResponses,
|
||||
GetRawLogsData,
|
||||
GetRawLogsError,
|
||||
GetRawLogsErrors,
|
||||
@@ -348,31 +305,11 @@ export type {
|
||||
GetWorkspaceResponses,
|
||||
GlobalSubgraphData,
|
||||
GlobalSubgraphInfo,
|
||||
HubAssetUploadUrlRequest,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
HubProfileSummary,
|
||||
HubUsernameCheckResponse,
|
||||
HubWorkflowDetail,
|
||||
HubWorkflowListResponse,
|
||||
HubWorkflowStatus,
|
||||
HubWorkflowSummary,
|
||||
HubWorkflowTemplateEntry,
|
||||
ImportPublishedAssetsData,
|
||||
ImportPublishedAssetsError,
|
||||
ImportPublishedAssetsErrors,
|
||||
ImportPublishedAssetsRequest,
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
JwksResponse,
|
||||
LabelRef,
|
||||
LeaveWorkspaceData,
|
||||
LeaveWorkspaceError,
|
||||
LeaveWorkspaceErrors,
|
||||
@@ -385,21 +322,6 @@ export type {
|
||||
ListAssetsResponse2,
|
||||
ListAssetsResponses,
|
||||
ListAssetsResponseWritable,
|
||||
ListHubLabelsData,
|
||||
ListHubLabelsError,
|
||||
ListHubLabelsErrors,
|
||||
ListHubLabelsResponse,
|
||||
ListHubLabelsResponses,
|
||||
ListHubWorkflowIndexData,
|
||||
ListHubWorkflowIndexError,
|
||||
ListHubWorkflowIndexErrors,
|
||||
ListHubWorkflowIndexResponse,
|
||||
ListHubWorkflowIndexResponses,
|
||||
ListHubWorkflowsData,
|
||||
ListHubWorkflowsError,
|
||||
ListHubWorkflowsErrors,
|
||||
ListHubWorkflowsResponse,
|
||||
ListHubWorkflowsResponses,
|
||||
ListInvitesResponse,
|
||||
ListMembersResponse,
|
||||
ListSecretsData,
|
||||
@@ -454,11 +376,6 @@ export type {
|
||||
PlanAvailability,
|
||||
PlanAvailabilityReason,
|
||||
PlanSeatSummary,
|
||||
PostAssetsFromWorkflowData,
|
||||
PostAssetsFromWorkflowError,
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PreviewPlanInfo,
|
||||
PreviewSubscribeData,
|
||||
PreviewSubscribeError,
|
||||
@@ -467,13 +384,6 @@ export type {
|
||||
PreviewSubscribeResponse,
|
||||
PreviewSubscribeResponse2,
|
||||
PreviewSubscribeResponses,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
PublishHubWorkflowErrors,
|
||||
PublishHubWorkflowRequest,
|
||||
PublishHubWorkflowResponse,
|
||||
PublishHubWorkflowResponses,
|
||||
RawLogsResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
@@ -511,13 +421,6 @@ export type {
|
||||
SendUserInviteEmailResponse,
|
||||
SendUserInviteEmailResponse2,
|
||||
SendUserInviteEmailResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
SetReviewStatusRequest,
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -552,12 +455,6 @@ export type {
|
||||
UpdateAssetTagsErrors,
|
||||
UpdateAssetTagsResponse,
|
||||
UpdateAssetTagsResponses,
|
||||
UpdateHubProfileData,
|
||||
UpdateHubProfileError,
|
||||
UpdateHubProfileErrors,
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateSecretData,
|
||||
UpdateSecretError,
|
||||
UpdateSecretErrors,
|
||||
@@ -589,8 +486,6 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
WorkflowApiAssetsRequest,
|
||||
WorkflowApiAssetsResponse,
|
||||
WorkflowForkedFrom,
|
||||
WorkflowListResponse,
|
||||
WorkflowResponse,
|
||||
|
||||
1082
packages/ingest-types/src/types.gen.ts
generated
1082
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
441
packages/ingest-types/src/zod.gen.ts
generated
441
packages/ingest-types/src/zod.gen.ts
generated
@@ -2,220 +2,6 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
export const zHubUsernameCheckResponse = z.object({
|
||||
username: z.string(),
|
||||
available: z.boolean(),
|
||||
suggestions: z.array(z.string()).optional(),
|
||||
validation_error: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubAssetUploadUrlResponse = z.object({
|
||||
upload_url: z.string(),
|
||||
public_url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
|
||||
export const zHubAssetUploadUrlRequest = z.object({
|
||||
filename: z.string(),
|
||||
content_type: z.string()
|
||||
})
|
||||
|
||||
export const zPublishHubWorkflowRequest = z.object({
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
workflow_filename: z.string(),
|
||||
asset_ids: z.array(z.string()),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
custom_nodes: z.array(z.string()).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_token_or_url: z.string().optional(),
|
||||
thumbnail_comparison_token_or_url: z.string().optional(),
|
||||
sample_image_tokens_or_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zAssetInfo = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
preview_url: z.string(),
|
||||
storage_url: z.string(),
|
||||
model: z.boolean(),
|
||||
public: z.boolean(),
|
||||
in_library: z.boolean()
|
||||
})
|
||||
|
||||
export const zHubProfileSummary = z.object({
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
avatar_url: z.string().optional()
|
||||
})
|
||||
|
||||
export const zLabelRef = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Public workflow status. NULL in the database is represented as pending in API responses.
|
||||
*/
|
||||
export const zHubWorkflowStatus = z.enum([
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'deprecated'
|
||||
])
|
||||
|
||||
export const zHubWorkflowDetail = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
name: z.string(),
|
||||
status: zHubWorkflowStatus,
|
||||
description: z.string().optional(),
|
||||
tags: z.array(zLabelRef).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
thumbnail_comparison_url: z.string().optional(),
|
||||
models: z.array(zLabelRef).optional(),
|
||||
custom_nodes: z.array(zLabelRef).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
sample_image_urls: z.array(z.string()).optional(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
workflow_json: z.record(z.unknown()),
|
||||
assets: z.array(zAssetInfo),
|
||||
profile: zHubProfileSummary
|
||||
})
|
||||
|
||||
export const zHubWorkflowSummary = z.object({
|
||||
share_id: z.string(),
|
||||
name: z.string(),
|
||||
status: zHubWorkflowStatus,
|
||||
description: z.string().optional(),
|
||||
tags: z.array(zLabelRef).optional(),
|
||||
models: z.array(zLabelRef).optional(),
|
||||
custom_nodes: z.array(zLabelRef).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
thumbnail_comparison_url: z.string().optional(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
profile: zHubProfileSummary,
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
sample_image_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowListResponse = z.object({
|
||||
workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubLabelInfo = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['tag', 'model', 'custom_node'])
|
||||
})
|
||||
|
||||
export const zHubLabelListResponse = z.object({
|
||||
labels: z.array(zHubLabelInfo)
|
||||
})
|
||||
|
||||
export const zHubWorkflowTemplateEntry = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
status: zHubWorkflowStatus,
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
requiresCustomNodes: z.array(z.string()).optional(),
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().optional(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
logos: z.array(z.record(z.unknown())).optional(),
|
||||
date: z.string().optional(),
|
||||
io: z
|
||||
.object({
|
||||
inputs: z.array(z.record(z.unknown())).optional(),
|
||||
outputs: z.array(z.record(z.unknown())).optional()
|
||||
})
|
||||
.optional(),
|
||||
includeOnDistributions: z.array(z.string()).optional(),
|
||||
thumbnailUrl: z.string().optional(),
|
||||
thumbnailComparisonUrl: z.string().optional(),
|
||||
shareId: z.string().optional(),
|
||||
extendedDescription: z.string().optional(),
|
||||
metaDescription: z.string().optional(),
|
||||
howToUse: z.array(z.string()).optional(),
|
||||
suggestedUseCases: z.array(z.string()).optional(),
|
||||
faqItems: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
answer: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contentTemplate: z.string().optional()
|
||||
})
|
||||
|
||||
export const zUpdateHubProfileRequest = z.object({
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_token: z.string().nullish(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zCreateHubProfileRequest = z.object({
|
||||
workspace_id: z.string(),
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_token: z.string().optional(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zHubProfile = z.object({
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zImportPublishedAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
export const zPublishedWorkflowDetail = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
name: z.string(),
|
||||
listed: z.boolean(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
workflow_json: z.record(z.unknown()),
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zWorkflowApiAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zWorkflowApiAssetsRequest = z.object({
|
||||
workflow_api_json: z.record(z.unknown())
|
||||
})
|
||||
|
||||
export const zForkWorkflowRequest = z.object({
|
||||
source_version: z.number().int(),
|
||||
name: z.string().optional()
|
||||
@@ -883,16 +669,6 @@ export const zSendUserInviteEmailRequest = z.object({
|
||||
force: z.boolean().optional().default(false)
|
||||
})
|
||||
|
||||
export const zSetReviewStatusResponse = z.object({
|
||||
share_ids: z.array(z.string()),
|
||||
status: z.enum(['approved', 'rejected'])
|
||||
})
|
||||
|
||||
export const zSetReviewStatusRequest = z.object({
|
||||
share_ids: z.array(z.string()).min(1),
|
||||
status: z.enum(['approved', 'rejected'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after successfully claiming an invite code
|
||||
*/
|
||||
@@ -1216,9 +992,7 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
include_public: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -1460,28 +1234,6 @@ export const zCheckAssetByHashData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPostAssetsFromWorkflowData = z.object({
|
||||
body: zWorkflowApiAssetsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zPostAssetsFromWorkflowResponse = zWorkflowApiAssetsResponse
|
||||
|
||||
export const zImportPublishedAssetsData = z.object({
|
||||
body: zImportPublishedAssetsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully imported assets
|
||||
*/
|
||||
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
|
||||
|
||||
export const zListSecretsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1860,17 +1612,6 @@ export const zSendUserInviteEmailData = z.object({
|
||||
*/
|
||||
export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse
|
||||
|
||||
export const zSetReviewStatusData = z.object({
|
||||
body: zSetReviewStatusRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Status updated successfully
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zGetDeletionRequestData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1892,13 +1633,6 @@ export const zCreateDeletionRequestData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Created - deletion request created or already exists
|
||||
*/
|
||||
export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -2194,176 +1928,3 @@ export const zForkWorkflowData = z.object({
|
||||
* Workflow forked successfully
|
||||
*/
|
||||
export const zForkWorkflowResponse = zWorkflowResponse
|
||||
|
||||
export const zCreateHubProfileData = z.object({
|
||||
body: zCreateHubProfileRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile created
|
||||
*/
|
||||
export const zCreateHubProfileResponse = zHubProfile
|
||||
|
||||
export const zGetMyHubProfileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile
|
||||
*/
|
||||
export const zGetMyHubProfileResponse = zHubProfile
|
||||
|
||||
export const zCheckHubUsernameData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
username: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Username availability result
|
||||
*/
|
||||
export const zCheckHubUsernameResponse = zHubUsernameCheckResponse
|
||||
|
||||
export const zGetHubProfileByUsernameData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile
|
||||
*/
|
||||
export const zGetHubProfileByUsernameResponse = zHubProfile
|
||||
|
||||
export const zUpdateHubProfileData = z.object({
|
||||
body: zUpdateHubProfileRequest,
|
||||
path: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile updated
|
||||
*/
|
||||
export const zUpdateHubProfileResponse = zHubProfile
|
||||
|
||||
export const zCreateHubAssetUploadUrlData = z.object({
|
||||
body: zHubAssetUploadUrlRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Presigned upload URL and token
|
||||
*/
|
||||
export const zCreateHubAssetUploadUrlResponse = zHubAssetUploadUrlResponse
|
||||
|
||||
export const zListHubLabelsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
type: z.enum(['tag', 'model', 'custom_node']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of labels
|
||||
*/
|
||||
export const zListHubLabelsResponse = zHubLabelListResponse
|
||||
|
||||
export const zListHubWorkflowsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().gte(1).lte(100).optional().default(20),
|
||||
search: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
detail: z.boolean().optional().default(false),
|
||||
status: z.array(zHubWorkflowStatus).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated list of hub workflows
|
||||
*/
|
||||
export const zListHubWorkflowsResponse = zHubWorkflowListResponse
|
||||
|
||||
export const zPublishHubWorkflowData = z.object({
|
||||
body: zPublishHubWorkflowRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow published to hub
|
||||
*/
|
||||
export const zPublishHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zListHubWorkflowIndexData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
status: z.array(zHubWorkflowStatus).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of hub workflow template entries
|
||||
*/
|
||||
export const zListHubWorkflowIndexResponse = z.array(zHubWorkflowTemplateEntry)
|
||||
|
||||
export const zDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully unpublished
|
||||
*/
|
||||
export const zDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zGetHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub workflow detail
|
||||
*/
|
||||
export const zGetHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zGetPublishedWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Published workflow details with asset statuses
|
||||
*/
|
||||
export const zGetPublishedWorkflowResponse = zPublishedWorkflowDetail
|
||||
|
||||
25
src/App.vue
25
src/App.vue
@@ -7,15 +7,17 @@
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -127,5 +129,26 @@ onMounted(() => {
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
|
||||
// Show cloud notification for macOS desktop users (one-time)
|
||||
if (isDesktop && electronAPI()?.getPlatform() === 'darwin') {
|
||||
const settingStore = useSettingStore()
|
||||
if (!settingStore.get('Comfy.Desktop.CloudNotificationShown')) {
|
||||
const dialogService = useDialogService()
|
||||
cloudNotificationTimer = setTimeout(async () => {
|
||||
try {
|
||||
await dialogService.showCloudNotification()
|
||||
} catch (e) {
|
||||
console.warn('[CloudNotification] Failed to show', e)
|
||||
}
|
||||
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onUnmounted(() => {
|
||||
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -71,8 +71,8 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
currentUser: null,
|
||||
loading: false
|
||||
}))
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</div>
|
||||
<Menu
|
||||
v-if="isActive || isRoot"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-check-big] size-4 text-green-500"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppBody')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphBody')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppPrompt')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="appliedAsApp">
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
|
||||
{{ $t('builderToolbar.exitToWorkflow') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
appliedAsApp: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
viewApp: []
|
||||
close: []
|
||||
exitToWorkflow: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-116 flex-col rounded-2xl bg-base-background">
|
||||
<div
|
||||
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
|
||||
@@ -11,11 +11,11 @@ import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
const mockSave = vi.hoisted(() => vi.fn())
|
||||
const mockSaveAs = vi.hoisted(() => vi.fn())
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockState = {
|
||||
mode: 'builder:inputs' as AppMode
|
||||
mode: 'builder:select' as AppMode,
|
||||
settingView: false
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
@@ -42,37 +42,10 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockActiveWorkflow = ref<{
|
||||
isTemporary: boolean
|
||||
initialMode?: string
|
||||
isModified?: boolean
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null>({
|
||||
isTemporary: true,
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./useBuilderSave', () => ({
|
||||
useBuilderSave: () => ({
|
||||
save: mockSave,
|
||||
saveAs: mockSaveAs,
|
||||
isSaving: { value: false }
|
||||
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
|
||||
useAppSetDefaultView: () => ({
|
||||
settingView: computed(() => mockState.settingView),
|
||||
showDialog: mockShowDialog
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -82,17 +55,7 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
builderMenu: { exitAppBuilder: 'Exit app builder' },
|
||||
builderToolbar: {
|
||||
viewApp: 'View app',
|
||||
saveAs: 'Save as',
|
||||
app: 'App',
|
||||
nodeGraph: 'Node graph'
|
||||
},
|
||||
builderFooter: {
|
||||
opensAsApp: 'Open as an {mode}',
|
||||
opensAsGraph: 'Open as a {mode}'
|
||||
},
|
||||
g: { back: 'Back', next: 'Next', save: 'Save' }
|
||||
g: { back: 'Back', next: 'Next' }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -103,7 +66,7 @@ describe('BuilderFooterToolbar', () => {
|
||||
vi.clearAllMocks()
|
||||
mockState.mode = 'builder:inputs'
|
||||
mockHasOutputs.value = true
|
||||
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
|
||||
mockState.settingView = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -112,11 +75,7 @@ describe('BuilderFooterToolbar', () => {
|
||||
render(BuilderFooterToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: false,
|
||||
BuilderOpensAsPopover: true,
|
||||
ConnectOutputPopover: { template: '<div><slot /></div>' }
|
||||
}
|
||||
stubs: { Button: false }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,12 +88,18 @@ describe('BuilderFooterToolbar', () => {
|
||||
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables back on the arrange step', () => {
|
||||
it('enables back on the second step', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: /back/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('disables next on the setDefaultView step', () => {
|
||||
mockState.settingView = true
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables next on arrange step when no outputs', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
mockHasOutputs.value = false
|
||||
@@ -162,55 +127,17 @@ describe('BuilderFooterToolbar', () => {
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('opens default view dialog on next click from arrange step', async () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
|
||||
expect(mockShowDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls exitBuilder on exit button click', async () => {
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: /exit app builder/i }))
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls setMode app on view app click', async () => {
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: /view app/i }))
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('shows "Save as" when workflow is temporary', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: true }
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: 'Save as' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Save" when workflow is saved', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false }
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls saveAs when workflow is temporary', async () => {
|
||||
mockActiveWorkflow.value = { isTemporary: true }
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Save as' }))
|
||||
expect(mockSaveAs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls save when workflow is saved and modified', async () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false, isModified: true }
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(mockSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables save button when workflow has no unsaved changes', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false, isModified: false }
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not call save when no outputs', async () => {
|
||||
mockHasOutputs.value = false
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Save as' }))
|
||||
expect(mockSave).not.toHaveBeenCalled()
|
||||
expect(mockSaveAs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,160 +1,46 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 flex-col items-center"
|
||||
<nav
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<!-- "Opens as" attachment tab -->
|
||||
<BuilderOpensAsPopover
|
||||
v-if="isSaved"
|
||||
:is-app-mode="isAppMode"
|
||||
@select="onSetDefaultView"
|
||||
/>
|
||||
|
||||
<!-- Main toolbar -->
|
||||
<nav
|
||||
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="onViewApp">
|
||||
{{ t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button size="lg" :class="cn('w-24', disabledSaveClasses)">
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
<ButtonGroup
|
||||
v-else-if="isSaved"
|
||||
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
>
|
||||
<DropdownMenuItem as-child @select="saveAs()">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full justify-start font-normal"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode, setMode } = useAppMode()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
goBack,
|
||||
goNext
|
||||
} = useBuilderSteps({
|
||||
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
|
||||
hasOutputs
|
||||
})
|
||||
const { save, saveAs } = useBuilderSave()
|
||||
|
||||
const isSaved = computed(
|
||||
() => workflowStore.activeWorkflow?.isTemporary === false
|
||||
)
|
||||
|
||||
const activeSaveClasses =
|
||||
'bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/80'
|
||||
const disabledSaveClasses =
|
||||
'bg-secondary-background text-muted-foreground/50 disabled:opacity-100'
|
||||
|
||||
const isModified = computed(
|
||||
() => workflowStore.activeWorkflow?.isModified === true
|
||||
)
|
||||
|
||||
const isAppMode = computed(
|
||||
() => workflowStore.activeWorkflow?.initialMode !== 'graph'
|
||||
)
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -174,14 +60,4 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
function onExitBuilder() {
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
|
||||
function onViewApp() {
|
||||
setMode('app')
|
||||
}
|
||||
|
||||
function onSetDefaultView(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
setWorkflowDefaultView(workflow, openAsApp)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<PopoverRoot>
|
||||
<PopoverAnchor as-child>
|
||||
<div
|
||||
data-testid="builder-opens-as"
|
||||
class="flex h-8 min-w-64 items-center justify-center gap-2 rounded-t-2xl bg-interface-builder-mode-footer-background px-4 text-sm text-interface-builder-mode-button-foreground"
|
||||
>
|
||||
<i :class="cn(currentModeIcon, 'size-4')" aria-hidden="true" />
|
||||
<i18n-t
|
||||
:keypath="
|
||||
isAppMode
|
||||
? 'builderFooter.opensAsApp'
|
||||
: 'builderFooter.opensAsGraph'
|
||||
"
|
||||
tag="span"
|
||||
>
|
||||
<template #mode>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
class="-ml-0.5 h-6 gap-1 rounded-md border-none bg-transparent px-1.5 text-sm text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/70"
|
||||
>
|
||||
{{
|
||||
isAppMode
|
||||
? t('builderToolbar.app').toLowerCase()
|
||||
: t('builderToolbar.nodeGraph').toLowerCase()
|
||||
}}
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<ViewTypeRadioGroup
|
||||
:model-value="isAppMode"
|
||||
:aria-label="t('builderToolbar.defaultViewLabel')"
|
||||
size="sm"
|
||||
@update:model-value="$emit('select', $event)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
|
||||
|
||||
const { isAppMode } = defineProps<{
|
||||
isAppMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [openAsApp: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentModeIcon = computed(() =>
|
||||
isAppMode ? 'icon-[lucide--app-window]' : 'icon-[comfy--workflow]'
|
||||
)
|
||||
</script>
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<BuilderDialog @close="emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :for="inputId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.filename') }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="filename"
|
||||
autofocus
|
||||
type="text"
|
||||
class="focus-visible:ring-ring flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground"
|
||||
@keydown.enter="
|
||||
filename.trim() && emit('save', filename.trim(), openAsApp)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :id="radioGroupLabelId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<ViewTypeRadioGroup
|
||||
v-model="openAsApp"
|
||||
:aria-labelledby="radioGroupLabelId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="emit('save', filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
|
||||
|
||||
const { defaultFilename, defaultOpenAsApp = true } = defineProps<{
|
||||
defaultFilename: string
|
||||
defaultOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [filename: string, openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const radioGroupLabelId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(defaultOpenAsApp)
|
||||
</script>
|
||||
@@ -23,21 +23,55 @@
|
||||
<StepLabel :step />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="mx-1 h-px w-4 bg-border-default"
|
||||
role="separator"
|
||||
/>
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Default view -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'setDefaultView'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'bg-transparent hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="navigateToStep('setDefaultView')"
|
||||
>
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
@@ -45,7 +79,9 @@ import type { BuilderStepId } from './useBuilderSteps'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { activeStep, navigateToStep } = useBuilderSteps()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
@@ -71,5 +107,11 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'setDefaultView',
|
||||
title: t('builderToolbar.defaultView'),
|
||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||
icon: 'icon-[lucide--eye]'
|
||||
}
|
||||
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
|
||||
</script>
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
:side-offset="18"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
|
||||
>
|
||||
|
||||
97
src/components/builder/DefaultViewDialogContent.vue
Normal file
97
src/components/builder/DefaultViewDialogContent.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.defaultViewTitle') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
:class="
|
||||
cn(
|
||||
itemClasses,
|
||||
openAsApp === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="openAsApp = option.value"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="openAsApp === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
|
||||
{{ $t('g.apply') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { initialOpenAsApp = true } = defineProps<{
|
||||
initialOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
apply: [openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const openAsApp = ref(initialOpenAsApp)
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
|
||||
const itemClasses =
|
||||
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<div role="radiogroup" v-bind="$attrs" :class="cn('flex flex-col', gapClass)">
|
||||
<Button
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="modelValue === option.value"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background',
|
||||
heightClass,
|
||||
modelValue === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="
|
||||
modelValue !== option.value && emit('update:modelValue', option.value)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="modelValue === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { size = 'md' } = defineProps<{
|
||||
modelValue: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
const heightClass = size === 'sm' ? 'h-12' : 'h-14'
|
||||
const gapClass = size === 'sm' ? 'gap-1' : 'gap-2'
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMockLoadedWorkflow } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import type { setWorkflowDefaultView as SetWorkflowDefaultViewFn } from './builderViewOptions'
|
||||
|
||||
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackDefaultViewSet: mockTrackDefaultViewSet })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
describe('setWorkflowDefaultView', () => {
|
||||
let setWorkflowDefaultView: typeof SetWorkflowDefaultViewFn
|
||||
let app: { rootGraph: { extra: Record<string, unknown> } }
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./builderViewOptions')
|
||||
setWorkflowDefaultView = mod.setWorkflowDefaultView
|
||||
app = (await import('@/scripts/app')).app as typeof app
|
||||
app.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(app.rootGraph.extra.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls changeTracker.checkState', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks telemetry with correct default_view', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'app'
|
||||
})
|
||||
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'graph'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function setWorkflowDefaultView(
|
||||
workflow: LoadedComfyWorkflow,
|
||||
openAsApp: boolean
|
||||
) {
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
}
|
||||
240
src/components/builder/useAppSetDefaultView.test.ts
Normal file
240
src/components/builder/useAppSetDefaultView.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn(),
|
||||
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: null as {
|
||||
initialMode?: string | null
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
rootGraph: { extra: {} as Record<string, unknown> }
|
||||
}))
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
exitBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => mockAppModeStore
|
||||
}))
|
||||
|
||||
vi.mock('./DefaultViewDialogContent.vue', () => ({
|
||||
default: { name: 'MockDefaultViewDialogContent' }
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
|
||||
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
|
||||
}))
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
describe('useAppSetDefaultView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockApp.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
describe('settingView', () => {
|
||||
it('reflects dialogStore.isDialogOpen', () => {
|
||||
mockDialogStore.isDialogOpen.mockReturnValue(true)
|
||||
const { settingView } = useAppSetDefaultView()
|
||||
expect(settingView.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDialog', () => {
|
||||
it('opens dialog via dialogService', () => {
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when initialMode is not graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp false when initialMode is graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(false)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when no active workflow', () => {
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleApply', () => {
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('closes dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.key).toBe('builder-default-view-applied')
|
||||
expect(confirmCall.props.appliedAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes appliedAsApp false to confirmation dialog when graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.props.appliedAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applied dialog', () => {
|
||||
function applyAndGetConfirmDialog(openAsApp: boolean) {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
applyCall.props.onApply(openAsApp)
|
||||
|
||||
return mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
}
|
||||
|
||||
it('onViewApp sets mode to app and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onViewApp()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onExitToWorkflow exits builder and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onExitToWorkflow()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onClose closes confirmation dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
|
||||
mockDialogStore.closeDialog.mockClear()
|
||||
confirmCall.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/components/builder/useAppSetDefaultView.ts
Normal file
82
src/components/builder/useAppSetDefaultView.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
|
||||
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const DIALOG_KEY = 'builder-default-view'
|
||||
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
|
||||
|
||||
export function useAppSetDefaultView() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
|
||||
|
||||
function showDialog() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: DefaultViewDialogContent,
|
||||
props: {
|
||||
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
|
||||
onApply: handleApply,
|
||||
onClose: closeDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleApply(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
function showAppliedDialog(appliedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: APPLIED_DIALOG_KEY,
|
||||
component: BuilderDefaultModeAppliedDialogContent,
|
||||
props: {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
closeAppliedDialog()
|
||||
appModeStore.exitBuilder()
|
||||
},
|
||||
onClose: closeAppliedDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function closeAppliedDialog() {
|
||||
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
|
||||
}
|
||||
|
||||
return { settingView, showDialog }
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
|
||||
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
|
||||
const mockSaveWorkflowAs = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<boolean | null>>()
|
||||
)
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockSetWorkflowDefaultView = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockActiveWorkflow = ref<{
|
||||
filename: string
|
||||
initialMode?: string | null
|
||||
} | null>(null)
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ toastErrorHandler: mockToastErrorHandler })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEnterLinear: mockTrackEnterLinear })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
saveWorkflow: mockSaveWorkflow,
|
||||
saveWorkflowAs: mockSaveWorkflowAs
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: mockCloseDialog })
|
||||
}))
|
||||
|
||||
vi.mock('./builderViewOptions', () => ({
|
||||
setWorkflowDefaultView: mockSetWorkflowDefaultView
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
|
||||
showConfirmDialog: mockShowConfirmDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) return `${key}:${JSON.stringify(params)}`
|
||||
return key
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderSaveDialogContent.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
describe('useBuilderSave', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = null
|
||||
})
|
||||
|
||||
describe('save()', () => {
|
||||
it('does nothing when there is no active workflow', async () => {
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockSaveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves workflow directly without showing a dialog', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflow.mockResolvedValueOnce(undefined)
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts error on failure', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const error = new Error('save failed')
|
||||
mockSaveWorkflow.mockRejectedValueOnce(error)
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents concurrent saves', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
let resolveSave!: () => void
|
||||
mockSaveWorkflow.mockReturnValueOnce(
|
||||
new Promise<void>((r) => {
|
||||
resolveSave = r
|
||||
})
|
||||
)
|
||||
const { save, isSaving } = useBuilderSave()
|
||||
|
||||
const firstSave = save()
|
||||
expect(isSaving.value).toBe(true)
|
||||
|
||||
await save()
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSave()
|
||||
await firstSave
|
||||
expect(isSaving.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveAs()', () => {
|
||||
it('does nothing when there is no active workflow', () => {
|
||||
mockActiveWorkflow.value = null
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens save dialog with correct defaultFilename and defaultOpenAsApp', () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
|
||||
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(key).toBe(SAVE_DIALOG_KEY)
|
||||
expect(props.defaultFilename).toBe('my-workflow')
|
||||
expect(props.defaultOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes defaultOpenAsApp: false when initialMode is graph', () => {
|
||||
mockActiveWorkflow.value = {
|
||||
filename: 'my-workflow',
|
||||
initialMode: 'graph'
|
||||
}
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
const { props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(props.defaultOpenAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save dialog callbacks', () => {
|
||||
function getSaveDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = useBuilderSave()
|
||||
saveAs()
|
||||
return mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onSave calls saveWorkflowAs then setWorkflowDefaultView on success', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name'
|
||||
}
|
||||
)
|
||||
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('onSave uses fresh activeWorkflow reference for setWorkflowDefaultView', async () => {
|
||||
const newWorkflow = { filename: 'new-name', initialMode: 'app' }
|
||||
mockSaveWorkflowAs.mockImplementationOnce(async () => {
|
||||
mockActiveWorkflow.value = newWorkflow
|
||||
return true
|
||||
})
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(newWorkflow, true)
|
||||
})
|
||||
|
||||
it('onSave does not mutate or close when saveWorkflowAs returns falsy', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(null)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockSetWorkflowDefaultView).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onSave closes dialog and shows success dialog after successful save', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
|
||||
})
|
||||
|
||||
it('shows app success message when openAsApp is true', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyApp')
|
||||
})
|
||||
|
||||
it('shows graph success message with exit builder button when openAsApp is false', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyGraph')
|
||||
expect(successCall.footerProps.confirmText).toBe(
|
||||
'linearMode.builder.exit'
|
||||
)
|
||||
expect(successCall.footerProps.cancelText).toBe('builderToolbar.viewApp')
|
||||
})
|
||||
|
||||
it('onSave toasts error and closes dialog on failure', async () => {
|
||||
const error = new Error('save-as failed')
|
||||
mockSaveWorkflowAs.mockRejectedValueOnce(error)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
})
|
||||
|
||||
it('prevents concurrent handleSaveAs calls', async () => {
|
||||
let resolveSaveAs!: (v: boolean) => void
|
||||
mockSaveWorkflowAs.mockReturnValueOnce(
|
||||
new Promise<boolean>((r) => {
|
||||
resolveSaveAs = r
|
||||
})
|
||||
)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
const firstSave = onSave('new-name', true)
|
||||
|
||||
await onSave('other-name', true)
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSaveAs(true)
|
||||
await firstSave
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph success dialog callbacks', () => {
|
||||
async function getGraphSuccessDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { saveAs } = useBuilderSave()
|
||||
saveAs()
|
||||
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
}
|
||||
await onSave('new-name', false)
|
||||
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onConfirm closes dialog and exits builder', async () => {
|
||||
const { onConfirm } = await getGraphSuccessDialogProps()
|
||||
|
||||
onConfirm()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onCancel closes dialog and switches to app mode', async () => {
|
||||
const { onCancel } = await getGraphSuccessDialogProps()
|
||||
|
||||
onCancel()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'app_builder'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
export function useBuilderSave() {
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { setMode } = useAppMode()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function closeDialog(key: string) {
|
||||
dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveAs() {
|
||||
if (isSaving.value) return
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: SAVE_DIALOG_KEY,
|
||||
component: BuilderSaveDialogContent,
|
||||
props: {
|
||||
defaultFilename: workflow.filename,
|
||||
defaultOpenAsApp: workflow.initialMode !== 'graph',
|
||||
onSave: handleSaveAs,
|
||||
onClose: () => closeDialog(SAVE_DIALOG_KEY)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveAs(filename: string, openAsApp: boolean) {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (!activeWorkflow) return
|
||||
setWorkflowDefaultView(activeWorkflow, openAsApp)
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
showSuccessDialog(openAsApp ? 'app' : 'graph')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(viewType: 'app' | 'graph') {
|
||||
const promptText =
|
||||
viewType === 'app'
|
||||
? t('builderSave.successBodyApp')
|
||||
: t('builderSave.successBodyGraph')
|
||||
|
||||
showConfirmDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
headerProps: {
|
||||
title: t('builderSave.successTitle'),
|
||||
icon: 'icon-[lucide--circle-check-big] text-green-500'
|
||||
},
|
||||
props: { promptText, preserveNewlines: true },
|
||||
footerProps:
|
||||
viewType === 'graph'
|
||||
? {
|
||||
cancelText: t('builderToolbar.viewApp'),
|
||||
confirmText: t('linearMode.builder.exit'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
}
|
||||
: {
|
||||
cancelText: t('g.close'),
|
||||
confirmText: t('builderToolbar.viewApp'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { save, saveAs, isSaving }
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
const BUILDER_STEPS = [
|
||||
'builder:inputs',
|
||||
'builder:outputs',
|
||||
'builder:arrange'
|
||||
'builder:arrange',
|
||||
'setDefaultView'
|
||||
] as const
|
||||
|
||||
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
|
||||
@@ -16,8 +19,10 @@ const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
|
||||
|
||||
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
const { mode, isBuilderMode, setMode } = useAppMode()
|
||||
const { settingView, showDialog } = useAppSetDefaultView()
|
||||
|
||||
const activeStep = computed<BuilderStepId>(() => {
|
||||
if (settingView.value) return 'setDefaultView'
|
||||
if (isBuilderMode.value) {
|
||||
return mode.value as BuilderStepId
|
||||
}
|
||||
@@ -42,14 +47,23 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
activeStep.value === 'builder:outputs'
|
||||
)
|
||||
|
||||
function navigateToStep(stepId: BuilderStepId) {
|
||||
if (stepId === 'setDefaultView') {
|
||||
setMode('builder:arrange')
|
||||
showDialog()
|
||||
} else {
|
||||
setMode(stepId)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (isFirstStep.value) return
|
||||
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (isLastStep.value) return
|
||||
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -58,7 +72,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep: setMode,
|
||||
navigateToStep,
|
||||
goBack,
|
||||
goNext
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Badge from './Badge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Badges/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof Badge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
export const Warn: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Contrast: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeveritiesLabel: Story = {
|
||||
render: () => ({
|
||||
components: { Badge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge label="NEW" severity="default" />
|
||||
<Badge label="NEW" severity="secondary" />
|
||||
<Badge label="NEW" severity="warn" />
|
||||
<Badge label="NEW" severity="danger" />
|
||||
<Badge label="NEW" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSeveritiesDot: Story = {
|
||||
render: () => ({
|
||||
components: { Badge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="dot" severity="default" />
|
||||
<Badge variant="dot" severity="secondary" />
|
||||
<Badge variant="dot" severity="warn" />
|
||||
<Badge variant="dot" severity="danger" />
|
||||
<Badge variant="dot" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { Badge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge label="NEW" variant="label" />
|
||||
<span class="text-xs text-muted-foreground">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted-foreground">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted-foreground">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Badge from './Badge.vue'
|
||||
import { badgeVariants } from './badge.variants'
|
||||
|
||||
describe('Badge', () => {
|
||||
it('renders label text', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 'NEW' } })
|
||||
expect(wrapper.text()).toBe('NEW')
|
||||
})
|
||||
|
||||
it('renders numeric label', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 5 } })
|
||||
expect(wrapper.text()).toBe('5')
|
||||
})
|
||||
|
||||
it('defaults to dot variant when no label is provided', () => {
|
||||
const wrapper = mount(Badge)
|
||||
expect(wrapper.classes()).toContain('size-2')
|
||||
})
|
||||
|
||||
it('defaults to label variant when label is provided', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 'NEW' } })
|
||||
expect(wrapper.classes()).toContain('font-semibold')
|
||||
expect(wrapper.classes()).toContain('uppercase')
|
||||
})
|
||||
|
||||
it('applies circle variant', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: '3', variant: 'circle' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('size-3.5')
|
||||
})
|
||||
|
||||
it('merges custom class via cn()', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: 'Test', class: 'ml-2' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('ml-2')
|
||||
expect(wrapper.classes()).toContain('rounded-full')
|
||||
})
|
||||
|
||||
describe('twMerge preserves color alongside text-3xs font size', () => {
|
||||
it.each([
|
||||
['default', 'text-white'],
|
||||
['secondary', 'text-white'],
|
||||
['warn', 'text-white'],
|
||||
['danger', 'text-white'],
|
||||
['contrast', 'text-base-background']
|
||||
] as const)(
|
||||
'%s severity retains its text color class',
|
||||
(severity, expectedColor) => {
|
||||
const classes = badgeVariants({ severity, variant: 'label' })
|
||||
expect(classes).toContain(expectedColor)
|
||||
expect(classes).toContain('text-3xs')
|
||||
}
|
||||
)
|
||||
|
||||
it('cn() does not clobber text-white when merging with text-3xs', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: 'Test', severity: 'danger' }
|
||||
})
|
||||
const classList = wrapper.classes()
|
||||
expect(classList).toContain('text-white')
|
||||
expect(classList).toContain('text-3xs')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { badgeVariants } from './badge.variants'
|
||||
import type { BadgeVariants } from './badge.variants'
|
||||
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: BadgeVariants['severity']
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const badgeClass = computed(() =>
|
||||
cn(
|
||||
badgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
}),
|
||||
className
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClass">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@
|
||||
data-testid="badge-pill"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-2xs',
|
||||
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
|
||||
textColorClass
|
||||
)
|
||||
"
|
||||
|
||||
@@ -59,7 +59,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-2xs leading-none font-bold"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -32,8 +32,8 @@ const mockBalance = vi.hoisted(() => ({
|
||||
|
||||
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
balance: mockBalance.value,
|
||||
isFetchingBalance: mockIsFetchingBalance.value
|
||||
}))
|
||||
|
||||
@@ -30,14 +30,14 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
textClass?: string
|
||||
showCreditsOnly?: boolean
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const {
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
|
||||
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'inline-flex items-center justify-center rounded-full',
|
||||
variants: {
|
||||
severity: {
|
||||
default: 'bg-primary-background text-white',
|
||||
secondary: 'bg-secondary-background-hover text-white',
|
||||
warn: 'bg-warning-background text-white',
|
||||
danger: 'bg-destructive-background text-white',
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-3xs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
severity: 'default',
|
||||
variant: 'label'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -12,9 +12,9 @@ export const statusBadgeVariants = cva({
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
|
||||
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-3xs font-semibold'
|
||||
circle: 'size-3.5 text-xxxs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-inter text-sm font-bold text-base-foreground"
|
||||
>
|
||||
<i v-if="icon" :class="cn(icon, 'size-4')" aria-hidden="true" />
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
icon?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
key?: string
|
||||
headerProps?: ComponentAttrs<typeof ConfirmHeader>
|
||||
props?: ComponentAttrs<typeof ConfirmBody>
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
@@ -13,9 +12,8 @@ interface ConfirmDialogOptions {
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
const { headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
key,
|
||||
headerComponent: ConfirmHeader,
|
||||
component: ConfirmBody,
|
||||
footerComponent: ConfirmFooter,
|
||||
|
||||
@@ -147,7 +147,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
@@ -167,7 +167,7 @@ const { onSuccess } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authActions = useAuthActions()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
|
||||
@@ -156,7 +156,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -171,7 +171,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authActions = useAuthActions()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
@@ -21,10 +21,10 @@ import { ref } from 'vue'
|
||||
|
||||
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { updatePasswordSchema } from '@/schemas/signInSchema'
|
||||
|
||||
const authActions = useAuthActions()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const loading = ref(false)
|
||||
|
||||
const { onSuccess } = defineProps<{
|
||||
|
||||
@@ -116,12 +116,12 @@ 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 { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
@@ -133,8 +133,8 @@ interface CreditHistoryItemData {
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
@@ -18,8 +18,8 @@ import ApiKeyForm from './ApiKeyForm.vue'
|
||||
const mockStoreApiKey = vi.fn()
|
||||
const mockLoading = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
loading: mockLoading()
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -100,9 +100,9 @@ import {
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { apiKeySchema } from '@/schemas/signInSchema'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const comfyPlatformBaseUrl = computed(() =>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import SignInForm from './SignInForm.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof SignInForm>
|
||||
|
||||
// Mock firebase auth modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
@@ -33,17 +35,17 @@ vi.mock('firebase/auth', () => ({
|
||||
|
||||
// Mock the auth composables and stores
|
||||
const mockSendPasswordReset = vi.fn()
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
sendPasswordReset: mockSendPasswordReset
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockLoadingRef = ref(false)
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
let mockLoading = false
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
get loading() {
|
||||
return mockLoadingRef.value
|
||||
return mockLoading
|
||||
}
|
||||
}))
|
||||
}))
|
||||
@@ -56,145 +58,259 @@ vi.mock('primevue/usetoast', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const forgotPasswordText = enMessages.auth.login.forgotPassword
|
||||
const loginButtonText = enMessages.auth.login.loginButton
|
||||
|
||||
describe('SignInForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSendPasswordReset.mockReset()
|
||||
mockToastAdd.mockReset()
|
||||
mockLoadingRef.value = false
|
||||
mockLoading = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
const mountComponent = (
|
||||
props = {},
|
||||
options = {}
|
||||
): VueWrapper<ComponentInstance> => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const result = render(SignInForm, {
|
||||
|
||||
return mount(SignInForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, ToastService],
|
||||
components: { Form, Button, InputText, Password, ProgressSpinner }
|
||||
components: {
|
||||
Form,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
},
|
||||
props
|
||||
props,
|
||||
...options
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
function getEmailInput() {
|
||||
return screen.getByPlaceholderText(enMessages.auth.login.emailPlaceholder)
|
||||
}
|
||||
|
||||
function getPasswordInput() {
|
||||
return screen.getByPlaceholderText(
|
||||
enMessages.auth.login.passwordPlaceholder
|
||||
)
|
||||
}
|
||||
|
||||
describe('Forgot Password Link', () => {
|
||||
it('shows disabled style when email is empty', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
|
||||
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('shows toast and focuses email input when clicked while disabled', async () => {
|
||||
const { user } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
const emailInput = getEmailInput()
|
||||
const focusSpy = vi.spyOn(emailInput, 'focus')
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(forgotPasswordText))
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should show toast warning
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'warn',
|
||||
summary: enMessages.auth.login.emailPlaceholder,
|
||||
life: 5000
|
||||
})
|
||||
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
|
||||
// Should NOT call sendPasswordReset
|
||||
expect(mockSendPasswordReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
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(
|
||||
component,
|
||||
'handleForgotPassword'
|
||||
)
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.select-none'
|
||||
)
|
||||
|
||||
// Click the forgot password link
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
|
||||
// Should call handleForgotPassword
|
||||
expect(handleForgotPasswordSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit event when form is submitted with valid data', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
await user.type(getEmailInput(), 'test@example.com')
|
||||
await user.type(getPasswordInput(), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: loginButtonText }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
// Call onSubmit directly with valid data
|
||||
component.onSubmit({
|
||||
valid: true,
|
||||
values: { email: 'test@example.com', password: 'password123' }
|
||||
})
|
||||
|
||||
// Check emitted event
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual([
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not emit submit event when form data is invalid', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
it('does not emit submit event when form is invalid', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
await user.type(getEmailInput(), 'invalid-email')
|
||||
await user.type(getPasswordInput(), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: loginButtonText }))
|
||||
// Call onSubmit with invalid form
|
||||
component.onSubmit({ valid: false, values: {} })
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
// Should not emit submit event
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows spinner when loading', () => {
|
||||
mockLoadingRef.value = true
|
||||
renderComponent()
|
||||
it('shows spinner when loading', async () => {
|
||||
mockLoading = true
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: loginButtonText })
|
||||
).not.toBeInTheDocument()
|
||||
try {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(false)
|
||||
} catch (error) {
|
||||
// Fallback test - check HTML content if component rendering fails
|
||||
mockLoading = true
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.html()).toContain('p-progressspinner')
|
||||
expect(wrapper.html()).not.toContain('<button')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows button when not loading', () => {
|
||||
renderComponent()
|
||||
mockLoading = false
|
||||
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: loginButtonText })
|
||||
).toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Structure', () => {
|
||||
it('renders email input with correct attributes', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const emailInput = wrapper.findComponent(InputText)
|
||||
|
||||
const emailInput = getEmailInput()
|
||||
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-in-email')
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(emailInput).toHaveAttribute('name', 'email')
|
||||
expect(emailInput).toHaveAttribute('type', 'text')
|
||||
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
|
||||
expect(emailInput.attributes('autocomplete')).toBe('email')
|
||||
expect(emailInput.attributes('name')).toBe('email')
|
||||
expect(emailInput.attributes('type')).toBe('text')
|
||||
})
|
||||
|
||||
it('renders password input with correct attributes', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const passwordInput = wrapper.findComponent(Password)
|
||||
|
||||
const passwordInput = getPasswordInput()
|
||||
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-in-password')
|
||||
expect(passwordInput).toHaveAttribute('name', 'password')
|
||||
// Check props instead of attributes for Password component
|
||||
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
|
||||
// Password component passes name as prop, not attribute
|
||||
expect(passwordInput.props('name')).toBe('password')
|
||||
expect(passwordInput.props('feedback')).toBe(false)
|
||||
expect(passwordInput.props('toggleMask')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders form with correct resolver', () => {
|
||||
const wrapper = mountComponent()
|
||||
const form = wrapper.findComponent(Form)
|
||||
|
||||
expect(form.props('resolver')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forgot Password with valid email', () => {
|
||||
it('calls sendPasswordReset when email is valid', async () => {
|
||||
const { user } = renderComponent()
|
||||
describe('Focus Behavior', () => {
|
||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
await user.type(getEmailInput(), 'test@example.com')
|
||||
await user.click(screen.getByText(forgotPasswordText))
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
const wrapper = mountComponent()
|
||||
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: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with valid email
|
||||
await component.handleForgotPassword('test@example.com', true)
|
||||
|
||||
// Should NOT focus email input
|
||||
expect(document.getElementById).not.toHaveBeenCalled()
|
||||
expect(mockFocus).not.toHaveBeenCalled()
|
||||
|
||||
// Should call sendPasswordReset
|
||||
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,14 +88,14 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { signInSchema } from '@/schemas/signInSchema'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const toast = useToast()
|
||||
|
||||
@@ -127,6 +127,6 @@ const handleForgotPassword = async (
|
||||
document.getElementById(emailInputId)?.focus?.()
|
||||
return
|
||||
}
|
||||
await authActions.sendPasswordReset(email)
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,12 +54,12 @@ 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 { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import PasswordFields from './PasswordFields.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { fireEvent, render } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -130,10 +129,6 @@ describe('SelectionToolbox', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefMock = {
|
||||
type: 'TestNode',
|
||||
title: 'Test Node'
|
||||
} as unknown
|
||||
|
||||
// Mock the canvas to avoid "getCanvas: canvas is null" errors
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
@@ -141,8 +136,8 @@ describe('SelectionToolbox', () => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent(props = {}): { container: Element } {
|
||||
const { container } = render(SelectionToolbox, {
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(SelectionToolbox, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
@@ -174,9 +169,7 @@ describe('SelectionToolbox', () => {
|
||||
Load3DViewerButton: {
|
||||
template: '<div class="load-3d-viewer-button" />'
|
||||
},
|
||||
MaskEditorButton: {
|
||||
template: '<div class="mask-editor-button" />'
|
||||
},
|
||||
MaskEditorButton: { template: '<div class="mask-editor-button" />' },
|
||||
DeleteButton: {
|
||||
template:
|
||||
'<button data-testid="delete-button" class="delete-button" />'
|
||||
@@ -200,7 +193,6 @@ describe('SelectionToolbox', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
return { container }
|
||||
}
|
||||
|
||||
describe('Button Visibility Logic', () => {
|
||||
@@ -212,91 +204,91 @@ describe('SelectionToolbox', () => {
|
||||
it('should show info button only for single selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(container.querySelector('.info-button')).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.info-button').exists()).toBe(true)
|
||||
|
||||
// Multiple node selection - render in separate test scope
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
const { container: container2 } = renderComponent()
|
||||
expect(container2.querySelector('.info-button')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.info-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not show info button when node definition is not found', () => {
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
// mock nodedef and return null
|
||||
nodeDefMock = null
|
||||
const { container } = renderComponent()
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
// remount component
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.info-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(
|
||||
container.querySelector('[data-testid="color-picker-button"]')
|
||||
).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
const { container: container2 } = renderComponent()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(
|
||||
container2.querySelector('[data-testid="color-picker-button"]')
|
||||
).toBeTruthy()
|
||||
wrapper2.find('[data-testid="color-picker-button"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should show frame nodes only for multiple selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(container.querySelector('.frame-nodes')).toBeFalsy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
const { container: container2 } = renderComponent()
|
||||
expect(container2.querySelector('.frame-nodes')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show bypass button for appropriate selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(
|
||||
container.querySelector('[data-testid="bypass-button"]')
|
||||
).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
const { container: container2 } = renderComponent()
|
||||
expect(
|
||||
container2.querySelector('[data-testid="bypass-button"]')
|
||||
).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show common buttons for all selections', () => {
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
|
||||
expect(
|
||||
container.querySelector('[data-testid="delete-button"]')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
container.querySelector('[data-testid="convert-to-subgraph-button"]')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
container.querySelector('[data-testid="more-options-button"]')
|
||||
).toBeTruthy()
|
||||
wrapper.find('[data-testid="convert-to-subgraph-button"]').exists()
|
||||
).toBe(true)
|
||||
expect(wrapper.find('[data-testid="more-options-button"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should show mask editor only for single image nodes', () => {
|
||||
@@ -305,14 +297,15 @@ describe('SelectionToolbox', () => {
|
||||
// Single image node
|
||||
isImageNodeSpy.mockReturnValue(true)
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(container.querySelector('.mask-editor-button')).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
|
||||
|
||||
// Single non-image node
|
||||
isImageNodeSpy.mockReturnValue(false)
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container: container2 } = renderComponent()
|
||||
expect(container2.querySelector('.mask-editor-button')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show Color picker button only for single Load3D nodes', () => {
|
||||
@@ -321,14 +314,15 @@ describe('SelectionToolbox', () => {
|
||||
// Single Load3D node
|
||||
isLoad3dNodeSpy.mockReturnValue(true)
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(container.querySelector('.load-3d-viewer-button')).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
|
||||
|
||||
// Single non-Load3D node
|
||||
isLoad3dNodeSpy.mockReturnValue(false)
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container: container2 } = renderComponent()
|
||||
expect(container2.querySelector('.load-3d-viewer-button')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show ExecuteButton only when output nodes are selected', () => {
|
||||
@@ -341,20 +335,22 @@ describe('SelectionToolbox', () => {
|
||||
{ type: 'SaveImage' }
|
||||
] as LGraphNode[])
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
expect(container.querySelector('.execute-button')).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.execute-button').exists()).toBe(true)
|
||||
|
||||
// Without output node selected
|
||||
isOutputNodeSpy.mockReturnValue(false)
|
||||
filterOutputNodesSpy.mockReturnValue([])
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container: container2 } = renderComponent()
|
||||
expect(container2.querySelector('.execute-button')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.execute-button').exists()).toBe(false)
|
||||
|
||||
// No selection at all
|
||||
canvasStore.selectedItems = []
|
||||
const { container: container3 } = renderComponent()
|
||||
expect(container3.querySelector('.execute-button')).toBeFalsy()
|
||||
wrapper2.unmount()
|
||||
const wrapper3 = mountComponent()
|
||||
expect(wrapper3.find('.execute-button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,20 +358,19 @@ describe('SelectionToolbox', () => {
|
||||
it('should show dividers between button groups when both groups have buttons', () => {
|
||||
// Setup single node to show info + other buttons
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const dividers = container.querySelectorAll('.vertical-divider')
|
||||
const dividers = wrapper.findAll('.vertical-divider')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show dividers when adjacent groups are empty', () => {
|
||||
// No selection should show minimal buttons and dividers
|
||||
canvasStore.selectedItems = []
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="more-options-button"]')
|
||||
).toBeTruthy()
|
||||
const buttons = wrapper.find('.panel').element.children
|
||||
expect(buttons.length).toBeGreaterThan(0) // At least MoreOptions should show
|
||||
})
|
||||
})
|
||||
|
||||
@@ -395,9 +390,9 @@ describe('SelectionToolbox', () => {
|
||||
} as ReturnType<typeof useExtensionService>)
|
||||
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(container.querySelector('.extension-command-button')).toBeTruthy()
|
||||
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not render extension commands when none available', () => {
|
||||
@@ -405,9 +400,47 @@ describe('SelectionToolbox', () => {
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(container.querySelector('.extension-command-button')).toBeFalsy()
|
||||
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Container Styling', () => {
|
||||
it('should apply minimap container styles', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
expect(panel.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should have correct CSS classes', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
expect(panel.classes()).toContain('selection-toolbox')
|
||||
expect(panel.classes()).toContain('absolute')
|
||||
expect(panel.classes()).toContain('left-1/2')
|
||||
expect(panel.classes()).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('should handle animation class conditionally', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
expect(panel.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -428,11 +461,10 @@ describe('SelectionToolbox', () => {
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = container.querySelector('.panel')
|
||||
expect(panel).toBeTruthy()
|
||||
await fireEvent.wheel(panel!)
|
||||
const panel = wrapper.find('.panel')
|
||||
await panel.trigger('wheel')
|
||||
|
||||
expect(forwardEventToCanvasSpy).toHaveBeenCalled()
|
||||
})
|
||||
@@ -446,12 +478,12 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should hide most buttons when no items selected', () => {
|
||||
canvasStore.selectedItems = []
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
expect(container.querySelector('.color-picker-button')).toBeFalsy()
|
||||
expect(container.querySelector('.frame-nodes')).toBeFalsy()
|
||||
expect(container.querySelector('.bookmark-button')).toBeFalsy()
|
||||
expect(wrapper.find('.info-button').exists()).toBe(false)
|
||||
expect(wrapper.find('.color-picker-button').exists()).toBe(false)
|
||||
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
|
||||
expect(wrapper.find('.bookmark-button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
data-testid="zoom-in-action"
|
||||
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
|
||||
@mouseup="stopRepeat"
|
||||
@mouseleave="stopRepeat"
|
||||
@@ -24,7 +23,6 @@
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
data-testid="zoom-out-action"
|
||||
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
|
||||
@mouseup="stopRepeat"
|
||||
@mouseleave="stopRepeat"
|
||||
@@ -37,7 +35,6 @@
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
data-testid="zoom-to-fit-action"
|
||||
@click="executeCommand('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
|
||||
@@ -49,7 +46,6 @@
|
||||
<div
|
||||
ref="zoomInputContainer"
|
||||
class="zoomInputContainer flex items-center gap-1 rounded-sm bg-input-surface p-2"
|
||||
data-testid="zoom-percentage-input"
|
||||
>
|
||||
<InputNumber
|
||||
:default-value="canvasStore.appScalePercentage"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
@@ -21,59 +21,26 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
const options = [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' },
|
||||
{ name: 'Option C', value: 'c' }
|
||||
]
|
||||
|
||||
function mountInParent(
|
||||
multiSelectProps: Record<string, unknown> = {},
|
||||
modelValue: { name: string; value: string }[] = []
|
||||
) {
|
||||
const parentEscapeCount = { value: 0 }
|
||||
|
||||
const Parent = {
|
||||
template:
|
||||
'<div @keydown.escape="onEsc"><MultiSelect v-model="sel" :options="options" v-bind="extraProps" /></div>',
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
return {
|
||||
sel: ref(modelValue),
|
||||
options,
|
||||
extraProps: multiSelectProps,
|
||||
onEsc: () => {
|
||||
parentEscapeCount.value++
|
||||
}
|
||||
describe('MultiSelect', () => {
|
||||
function createWrapper() {
|
||||
return mount(MultiSelect, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
},
|
||||
props: {
|
||||
modelValue: [],
|
||||
label: 'Category',
|
||||
options: [
|
||||
{ name: 'One', value: 'one' },
|
||||
{ name: 'Two', value: 'two' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const wrapper = mount(Parent, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { wrapper, parentEscapeCount }
|
||||
}
|
||||
|
||||
function dispatchEscape(element: Element) {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function findContentElement(): HTMLElement | null {
|
||||
return document.querySelector('[data-dismissable-layer]')
|
||||
}
|
||||
|
||||
describe('MultiSelect', () => {
|
||||
it('keeps open-state border styling available while the dropdown is open', async () => {
|
||||
const { wrapper } = mountInParent()
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
|
||||
|
||||
@@ -90,65 +57,4 @@ describe('MultiSelect', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
describe('Escape key propagation', () => {
|
||||
it('stops Escape from propagating to parent when popover is open', async () => {
|
||||
const { wrapper, parentEscapeCount } = mountInParent()
|
||||
|
||||
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const content = findContentElement()
|
||||
expect(content).not.toBeNull()
|
||||
|
||||
dispatchEscape(content!)
|
||||
await nextTick()
|
||||
|
||||
expect(parentEscapeCount.value).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes the popover when Escape is pressed', async () => {
|
||||
const { wrapper } = mountInParent()
|
||||
|
||||
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
|
||||
const content = findContentElement()
|
||||
dispatchEscape(content!)
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('data-state')).toBe('closed')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selected count badge', () => {
|
||||
it('shows selected count when items are selected', () => {
|
||||
const { wrapper } = mountInParent({}, [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' }
|
||||
])
|
||||
|
||||
expect(wrapper.text()).toContain('2')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not show count badge when no items are selected', () => {
|
||||
const { wrapper } = mountInParent()
|
||||
const multiSelect = wrapper.findComponent(MultiSelect)
|
||||
const spans = multiSelect.findAll('span')
|
||||
const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim()))
|
||||
|
||||
expect(countBadge).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
v-model="selectedItems"
|
||||
v-model:open="isOpen"
|
||||
multiple
|
||||
by="value"
|
||||
:disabled
|
||||
@@ -14,10 +13,17 @@
|
||||
:aria-label="label || t('g.multiSelectDropdown')"
|
||||
:class="
|
||||
cn(
|
||||
selectTriggerVariants({
|
||||
size,
|
||||
border: selectedCount > 0 ? 'active' : 'none'
|
||||
})
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
selectedCount > 0
|
||||
? 'border-base-foreground'
|
||||
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
|
||||
disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -39,7 +45,9 @@
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div :class="selectDropdownClass">
|
||||
<div
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</div>
|
||||
</ComboboxTrigger>
|
||||
@@ -51,8 +59,19 @@
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="popoverStyle"
|
||||
:class="selectContentClass"
|
||||
@keydown="onContentKeydown"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 overflow-hidden',
|
||||
'rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2'
|
||||
)
|
||||
"
|
||||
@focus-outside="preventFocusDismiss"
|
||||
>
|
||||
<div
|
||||
@@ -113,7 +132,13 @@
|
||||
v-for="opt in filteredOptions"
|
||||
:key="opt.value"
|
||||
:value="opt"
|
||||
:class="cn('group', selectItemVariants({ layout: 'multi' }))"
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
|
||||
@@ -126,7 +151,7 @@
|
||||
</div>
|
||||
<span>{{ opt.name }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxEmpty :class="selectEmptyMessageClass">
|
||||
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
|
||||
{{ $t('g.noResultsFound') }}
|
||||
</ComboboxEmpty>
|
||||
</ComboboxViewport>
|
||||
@@ -151,21 +176,13 @@ import {
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport
|
||||
} from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import {
|
||||
selectContentClass,
|
||||
selectDropdownClass,
|
||||
selectEmptyMessageClass,
|
||||
selectItemVariants,
|
||||
selectTriggerVariants,
|
||||
stopEscapeToDocument
|
||||
} from './select.variants'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
defineOptions({
|
||||
@@ -215,16 +232,8 @@ const selectedItems = defineModel<SelectOption[]>({
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
function onContentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
stopEscapeToDocument(event)
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function preventFocusDismiss(event: FocusOutsideEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
singleSelectDropdown: 'Single-select dropdown'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const options = [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' },
|
||||
{ name: 'Option C', value: 'c' }
|
||||
]
|
||||
|
||||
function dispatchEscape(element: Element) {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function findContentElement(): HTMLElement | null {
|
||||
return document.querySelector('[data-dismissable-layer]')
|
||||
}
|
||||
|
||||
function mountInParent(modelValue?: string) {
|
||||
const parentEscapeCount = { value: 0 }
|
||||
|
||||
const Parent = {
|
||||
template:
|
||||
'<div @keydown.escape="onEsc"><SingleSelect v-model="sel" :options="options" label="Pick" /></div>',
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
return {
|
||||
sel: ref(modelValue),
|
||||
options,
|
||||
onEsc: () => {
|
||||
parentEscapeCount.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mount(Parent, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { wrapper, parentEscapeCount }
|
||||
}
|
||||
|
||||
async function openSelect(triggerEl: HTMLElement) {
|
||||
if (!triggerEl.hasPointerCapture) {
|
||||
triggerEl.hasPointerCapture = () => false
|
||||
triggerEl.releasePointerCapture = () => {}
|
||||
}
|
||||
triggerEl.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
button: 0,
|
||||
pointerType: 'mouse',
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('SingleSelect', () => {
|
||||
describe('Escape key propagation', () => {
|
||||
it('stops Escape from propagating to parent when popover is open', async () => {
|
||||
const { wrapper, parentEscapeCount } = mountInParent()
|
||||
|
||||
const trigger = wrapper.find('button[role="combobox"]')
|
||||
await openSelect(trigger.element as HTMLElement)
|
||||
|
||||
const content = findContentElement()
|
||||
expect(content).not.toBeNull()
|
||||
|
||||
dispatchEscape(content!)
|
||||
await nextTick()
|
||||
|
||||
expect(parentEscapeCount.value).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes the popover when Escape is pressed', async () => {
|
||||
const { wrapper } = mountInParent()
|
||||
|
||||
const trigger = wrapper.find('button[role="combobox"]')
|
||||
await openSelect(trigger.element as HTMLElement)
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
|
||||
const content = findContentElement()
|
||||
dispatchEscape(content!)
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('data-state')).toBe('closed')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,23 @@
|
||||
<template>
|
||||
<SelectRoot v-model="selectedItem" v-model:open="isOpen" :disabled>
|
||||
<SelectRoot v-model="selectedItem" :disabled>
|
||||
<SelectTrigger
|
||||
v-bind="$attrs"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:class="
|
||||
selectTriggerVariants({
|
||||
size,
|
||||
border: invalid ? 'invalid' : 'none'
|
||||
})
|
||||
cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid ? 'border-destructive-background' : 'border-transparent',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
@@ -27,7 +35,9 @@
|
||||
<slot v-else name="icon" />
|
||||
<SelectValue :placeholder="label" class="truncate" />
|
||||
</div>
|
||||
<div :class="selectDropdownClass">
|
||||
<div
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
@@ -38,8 +48,20 @@
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="optionStyle"
|
||||
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
|
||||
@keydown="onContentKeydown"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 overflow-hidden',
|
||||
'rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'min-w-(--reka-select-trigger-width)',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectViewport
|
||||
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
|
||||
@@ -49,7 +71,16 @@
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:class="selectItemVariants({ layout: 'single' })"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-pointer items-center justify-between select-none',
|
||||
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'focus:bg-secondary-background-hover',
|
||||
'data-[state=checked]:bg-secondary-background-selected',
|
||||
'data-[state=checked]:hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectItemText class="truncate">
|
||||
{{ opt.name }}
|
||||
@@ -81,19 +112,11 @@ import {
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import {
|
||||
selectContentClass,
|
||||
selectDropdownClass,
|
||||
selectItemVariants,
|
||||
selectTriggerVariants,
|
||||
stopEscapeToDocument
|
||||
} from './select.variants'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
defineOptions({
|
||||
@@ -132,14 +155,6 @@ const {
|
||||
const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
|
||||
function onContentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
stopEscapeToDocument(event)
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const optionStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const selectTriggerVariants = cva({
|
||||
base: 'relative inline-flex cursor-pointer items-center select-none rounded-lg bg-secondary-background text-base-foreground outline-none transition-all duration-200 ease-in-out hover:bg-secondary-background-hover border-[2.5px] border-solid disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background',
|
||||
variants: {
|
||||
size: {
|
||||
md: 'h-8',
|
||||
lg: 'h-10'
|
||||
},
|
||||
border: {
|
||||
none: 'border-transparent focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
|
||||
active: 'border-base-foreground',
|
||||
invalid: 'border-destructive-background'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'lg',
|
||||
border: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
export const selectItemVariants = cva({
|
||||
base: 'flex cursor-pointer items-center px-2 outline-none hover:bg-secondary-background-hover',
|
||||
variants: {
|
||||
layout: {
|
||||
multi:
|
||||
'h-10 shrink-0 gap-2 rounded-lg data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected',
|
||||
single:
|
||||
'relative w-full justify-between gap-3 rounded-sm py-3 text-sm select-none focus:bg-secondary-background-hover data-[state=checked]:bg-secondary-background-selected data-[state=checked]:hover:bg-secondary-background-selected'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
layout: 'multi'
|
||||
}
|
||||
})
|
||||
|
||||
export const selectContentClass =
|
||||
'z-3000 overflow-hidden rounded-lg p-2 bg-base-background text-base-foreground border border-solid border-border-default shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2'
|
||||
|
||||
export const selectDropdownClass =
|
||||
'flex shrink-0 cursor-pointer items-center justify-center px-3'
|
||||
|
||||
export const selectEmptyMessageClass = 'px-3 pb-4 text-sm text-muted-foreground'
|
||||
|
||||
export function stopEscapeToDocument(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="nodeDef.description"
|
||||
class="m-0 text-2xs/normal font-normal text-muted-foreground"
|
||||
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
|
||||
>
|
||||
{{ nodeDef.description }}
|
||||
</p>
|
||||
@@ -49,14 +49,14 @@
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<h4
|
||||
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{{ $t('nodeHelpPage.inputs') }}
|
||||
</h4>
|
||||
<div
|
||||
v-for="input in inputs"
|
||||
:key="input.name"
|
||||
class="flex items-center justify-between gap-2 text-2xs"
|
||||
class="flex items-center justify-between gap-2 text-xxs"
|
||||
>
|
||||
<span class="text-foreground shrink-0">{{ input.name }}</span>
|
||||
<span class="min-w-0 truncate text-muted-foreground">{{
|
||||
@@ -71,14 +71,14 @@
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<h4
|
||||
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{{ $t('nodeHelpPage.outputs') }}
|
||||
</h4>
|
||||
<div
|
||||
v-for="output in outputs"
|
||||
:key="output.name"
|
||||
class="flex items-center justify-between gap-2 text-2xs"
|
||||
class="flex items-center justify-between gap-2 text-xxs"
|
||||
>
|
||||
<span class="text-foreground shrink-0">{{ output.name }}</span>
|
||||
<span class="min-w-0 truncate text-muted-foreground">{{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
@@ -27,79 +25,61 @@ const JobFiltersBarStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const testJob: JobListItem = {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'pending'
|
||||
}
|
||||
|
||||
const JobAssetsListStub = defineComponent({
|
||||
const JobAssetsListStub = {
|
||||
name: 'JobAssetsList',
|
||||
setup(_, { emit }) {
|
||||
return {
|
||||
triggerCancel: () => emit('cancel-item', testJob),
|
||||
triggerDelete: () => emit('delete-item', testJob),
|
||||
triggerView: () => emit('view-item', testJob)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="job-assets-list-stub">
|
||||
<button data-testid="stub-cancel" @click="triggerCancel()" />
|
||||
<button data-testid="stub-delete" @click="triggerDelete()" />
|
||||
<button data-testid="stub-view" @click="triggerView()" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
}
|
||||
|
||||
const JobContextMenuStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
headerTitle: 'Jobs',
|
||||
queuedCount: 1,
|
||||
selectedJobTab: 'All' as const,
|
||||
selectedWorkflowFilter: 'all' as const,
|
||||
selectedSortMode: 'mostRecent' as const,
|
||||
displayedJobGroups: [],
|
||||
hasFailedJobs: false
|
||||
}
|
||||
const createJob = (): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'pending'
|
||||
})
|
||||
|
||||
const stubs = {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
}
|
||||
const mountComponent = () =>
|
||||
mount(QueueOverlayExpanded, {
|
||||
props: {
|
||||
headerTitle: 'Jobs',
|
||||
queuedCount: 1,
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
displayedJobGroups: [],
|
||||
hasFailedJobs: false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayExpanded', () => {
|
||||
it('renders JobAssetsList', () => {
|
||||
const { container } = render(QueueOverlayExpanded, {
|
||||
props: defaultProps,
|
||||
global: { stubs }
|
||||
})
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('.job-assets-list-stub')).toBeTruthy()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.job-assets-list-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('re-emits list item actions from JobAssetsList', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelItem = vi.fn<(item: JobListItem) => void>()
|
||||
const onDeleteItem = vi.fn<(item: JobListItem) => void>()
|
||||
const onViewItem = vi.fn<(item: JobListItem) => void>()
|
||||
const wrapper = mountComponent()
|
||||
const job = createJob()
|
||||
const jobAssetsList = wrapper.findComponent({ name: 'JobAssetsList' })
|
||||
|
||||
render(QueueOverlayExpanded, {
|
||||
props: { ...defaultProps, onCancelItem, onDeleteItem, onViewItem },
|
||||
global: { stubs }
|
||||
})
|
||||
jobAssetsList.vm.$emit('cancel-item', job)
|
||||
jobAssetsList.vm.$emit('delete-item', job)
|
||||
jobAssetsList.vm.$emit('view-item', job)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await user.click(screen.getByTestId('stub-cancel'))
|
||||
await user.click(screen.getByTestId('stub-delete'))
|
||||
await user.click(screen.getByTestId('stub-view'))
|
||||
|
||||
expect(onCancelItem).toHaveBeenCalledWith(testJob)
|
||||
expect(onDeleteItem).toHaveBeenCalledWith(testJob)
|
||||
expect(onViewItem).toHaveBeenCalledWith(testJob)
|
||||
expect(wrapper.emitted('cancelItem')?.[0]).toEqual([job])
|
||||
expect(wrapper.emitted('deleteItem')?.[0]).toEqual([job])
|
||||
expect(wrapper.emitted('viewItem')?.[0]).toEqual([job])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/* eslint-disable vue/one-component-per-file -- test stubs */
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
|
||||
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
@@ -18,36 +14,7 @@ const JobDetailsPopoverStub = defineComponent({
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template:
|
||||
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
|
||||
})
|
||||
|
||||
const AssetsListItemStub = defineComponent({
|
||||
name: 'AssetsListItem',
|
||||
props: {
|
||||
previewUrl: { type: String, default: undefined },
|
||||
isVideoPreview: { type: Boolean, default: false },
|
||||
previewAlt: { type: String, default: '' },
|
||||
iconName: { type: String, default: undefined },
|
||||
iconClass: { type: String, default: undefined },
|
||||
primaryText: { type: String, default: undefined },
|
||||
secondaryText: { type: String, default: undefined },
|
||||
progressTotalPercent: { type: Number, default: undefined },
|
||||
progressCurrentPercent: { type: Number, default: undefined }
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
return { emitPreviewClick: () => emit('preview-click') }
|
||||
},
|
||||
template: `
|
||||
<div class="assets-list-item-stub"
|
||||
:data-preview-url="previewUrl"
|
||||
:data-is-video="isVideoPreview">
|
||||
<span>{{ primaryText }}</span>
|
||||
<button data-testid="preview-trigger" @click="emitPreviewClick" />
|
||||
<i v-if="iconName && !previewUrl" :class="iconName" @click="emitPreviewClick" />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
`
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => {
|
||||
@@ -105,12 +72,7 @@ const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
function renderJobAssetsList(
|
||||
jobs: JobListItem[],
|
||||
callbacks: {
|
||||
onViewItem?: (item: JobListItem) => void
|
||||
} = {}
|
||||
) {
|
||||
const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'group-1',
|
||||
@@ -119,23 +81,15 @@ function renderJobAssetsList(
|
||||
}
|
||||
]
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
const result = render(JobAssetsList, {
|
||||
props: {
|
||||
displayedJobGroups,
|
||||
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
|
||||
},
|
||||
return mount(JobAssetsList, {
|
||||
props: { displayedJobGroups },
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub,
|
||||
AssetsListItem: AssetsListItemStub
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
function createDomRect({
|
||||
@@ -170,23 +124,24 @@ afterEach(() => {
|
||||
describe('JobAssetsList', () => {
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
const { user } = renderJobAssetsList([job], { onViewItem })
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
await user.click(screen.getByTestId('preview-trigger'))
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
listItem.vm.$emit('preview-click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
await user.dblClick(stubRoot)
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
@@ -194,18 +149,16 @@ describe('JobAssetsList', () => {
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
expect(stubRoot.getAttribute('data-preview-url')).toBe(
|
||||
'/api/view/job-1.webm'
|
||||
)
|
||||
expect(stubRoot.getAttribute('data-is-video')).toBe('true')
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
|
||||
expect(listItem.props('isVideoPreview')).toBe(true)
|
||||
|
||||
await user.dblClick(stubRoot)
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
@@ -213,13 +166,14 @@ describe('JobAssetsList', () => {
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const icon = container.querySelector('.assets-list-item-stub i')!
|
||||
await user.click(icon)
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
await listItem.find('i').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
@@ -227,13 +181,13 @@ describe('JobAssetsList', () => {
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
await user.dblClick(stubRoot)
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(onViewItem).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('viewItem')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits viewItem from the View button for completed jobs without preview output', async () => {
|
||||
@@ -241,90 +195,92 @@ describe('JobAssetsList', () => {
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef()
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container } = renderJobAssetsList([job], { onViewItem })
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await jobRow.trigger('mouseenter')
|
||||
const viewButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'menuLabels.View')
|
||||
expect(viewButton).toBeDefined()
|
||||
|
||||
await fireEvent.click(screen.getByText('menuLabels.View'))
|
||||
await viewButton!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('shows and hides the job details popover with hover delays', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
const popoverStub = container.querySelector('.job-details-popover-stub')!
|
||||
expect(popoverStub).not.toBeNull()
|
||||
expect(popoverStub.getAttribute('data-job-id')).toBe(job.id)
|
||||
expect(popoverStub.getAttribute('data-workflow-id')).toBe('workflow-1')
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: job.id,
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
|
||||
await fireEvent.mouseLeave(jobRow)
|
||||
await jobRow.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the job details popover open while hovering the popover', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.mouseLeave(jobRow)
|
||||
await jobRow.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
const popoverWrapper = container.querySelector('.job-details-popover')!
|
||||
expect(popoverWrapper).not.toBeNull()
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.exists()).toBe(true)
|
||||
|
||||
await fireEvent.mouseEnter(popoverWrapper)
|
||||
await popover.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await fireEvent.mouseLeave(popoverWrapper)
|
||||
await popover.trigger('mouseleave')
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('positions the popover to the right of rows near the left viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
||||
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
|
||||
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
|
||||
createDomRect({
|
||||
top: 100,
|
||||
left: 40,
|
||||
@@ -333,23 +289,22 @@ describe('JobAssetsList', () => {
|
||||
})
|
||||
)
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = container.querySelector('.job-details-popover')!
|
||||
expect(popover.getAttribute('style')).toContain('left: 248px;')
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.attributes('style')).toContain('left: 248px;')
|
||||
})
|
||||
|
||||
it('positions the popover to the left of rows near the right viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
||||
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
|
||||
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
|
||||
createDomRect({
|
||||
top: 100,
|
||||
left: 980,
|
||||
@@ -358,89 +313,83 @@ describe('JobAssetsList', () => {
|
||||
})
|
||||
)
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = container.querySelector('.job-details-popover')!
|
||||
expect(popover.getAttribute('style')).toContain('left: 672px;')
|
||||
const popover = wrapper.find('.job-details-popover')
|
||||
expect(popover.attributes('style')).toContain('left: 672px;')
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const { container } = renderJobAssetsList([firstJob, secondJob])
|
||||
const wrapper = mountJobAssetsList([firstJob, secondJob])
|
||||
const firstRow = wrapper.find('[data-job-id="job-1"]')
|
||||
const secondRow = wrapper.find('[data-job-id="job-2"]')
|
||||
|
||||
const firstRow = container.querySelector('[data-job-id="job-1"]')!
|
||||
const secondRow = container.querySelector('[data-job-id="job-2"]')!
|
||||
|
||||
await fireEvent.mouseEnter(firstRow)
|
||||
await firstRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
const popoverJobId = container
|
||||
.querySelector('.job-details-popover-stub')
|
||||
?.getAttribute('data-job-id')
|
||||
expect(popoverJobId).toBe('job-1')
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
|
||||
'job-1'
|
||||
)
|
||||
|
||||
await fireEvent.mouseLeave(firstRow)
|
||||
await fireEvent.mouseEnter(secondRow)
|
||||
await firstRow.trigger('mouseleave')
|
||||
await secondRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
await fireEvent.mouseLeave(secondRow)
|
||||
await secondRow.trigger('mouseleave')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const { container } = renderJobAssetsList([firstJob, secondJob])
|
||||
const wrapper = mountJobAssetsList([firstJob, secondJob])
|
||||
const firstRow = wrapper.find('[data-job-id="job-1"]')
|
||||
const secondRow = wrapper.find('[data-job-id="job-2"]')
|
||||
|
||||
const firstRow = container.querySelector('[data-job-id="job-1"]')!
|
||||
const secondRow = container.querySelector('[data-job-id="job-2"]')!
|
||||
|
||||
await fireEvent.mouseEnter(firstRow)
|
||||
await firstRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
const firstPopoverJobId = container
|
||||
.querySelector('.job-details-popover-stub')
|
||||
?.getAttribute('data-job-id')
|
||||
expect(firstPopoverJobId).toBe('job-1')
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
|
||||
'job-1'
|
||||
)
|
||||
|
||||
await fireEvent.mouseLeave(firstRow)
|
||||
await fireEvent.mouseEnter(secondRow)
|
||||
await firstRow.trigger('mouseleave')
|
||||
await secondRow.trigger('mouseenter')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await nextTick()
|
||||
|
||||
const popoverStub = container.querySelector('.job-details-popover-stub')!
|
||||
expect(popoverStub).not.toBeNull()
|
||||
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props('jobId')).toBe('job-2')
|
||||
})
|
||||
|
||||
it('does not show details if the hovered row disappears before the show delay ends', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container, rerender } = renderJobAssetsList([job])
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await rerender({ displayedJobGroups: [] })
|
||||
await jobRow.trigger('mouseenter')
|
||||
await wrapper.setProps({ displayedJobGroups: [] })
|
||||
await nextTick()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
expect(container.querySelector('.job-details-popover')).toBeNull()
|
||||
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
|
||||
expect(wrapper.find('.job-details-popover').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -10,12 +10,29 @@ import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
|
||||
const mockStoreRefs = vi.hoisted(() => ({
|
||||
visible: { value: false },
|
||||
newSearchBoxEnabled: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
const actual = await vi.importActual('pinia')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
storeToRefs: () => mockStoreRefs
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/workspace/searchBoxStore', () => ({
|
||||
useSearchBoxStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
getCanvasCenter: vi.fn(() => [0, 0]),
|
||||
@@ -51,9 +68,13 @@ vi.mock('@/stores/nodeDefStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
type EmitAddFilter = (
|
||||
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
) => void
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
template: '<div class="node-search-box" />'
|
||||
})
|
||||
|
||||
function createFilter(
|
||||
id: string,
|
||||
@@ -72,33 +93,15 @@ describe('NodeSearchBoxPopover', () => {
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
let emitAddFilter: EmitAddFilter | null = null
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockStoreRefs.visible.value = false
|
||||
})
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['addFilter'],
|
||||
setup(props, { emit }) {
|
||||
emitAddFilter = (filter) => emit('addFilter', filter)
|
||||
const filterCount = computed(() => props.filters.length)
|
||||
return { filterCount }
|
||||
},
|
||||
template: '<output aria-label="filter count">{{ filterCount }}</output>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
searchBox: { visible: false }
|
||||
}
|
||||
})
|
||||
|
||||
const result = render(NodeSearchBoxPopover, {
|
||||
const mountComponent = () => {
|
||||
return mount(NodeSearchBoxPopover, {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue, pinia],
|
||||
plugins: [i18n, PrimeVue],
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
Dialog: {
|
||||
@@ -108,53 +111,63 @@ describe('NodeSearchBoxPopover', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
|
||||
|
||||
return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter }
|
||||
}
|
||||
|
||||
describe('addFilter duplicate prevention', () => {
|
||||
it('should add a filter when no duplicates exist', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
|
||||
const filters = searchBox.props('filters') as FuseFilterWithValue<
|
||||
ComfyNodeDefImpl,
|
||||
string
|
||||
>[]
|
||||
expect(filters).toHaveLength(1)
|
||||
expect(filters[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'outputType' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add a duplicate filter with same id and value', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
|
||||
expect(searchBox.props('filters')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should allow filters with same id but different values', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
emitAddFilter(createFilter('outputType', 'MASK'))
|
||||
await nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should allow filters with different ids but same value', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
emitAddFilter(createFilter('inputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="flex items-center gap-1 text-2xs text-muted-foreground"
|
||||
class="flex items-center gap-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
v-if="shouldShowBadge"
|
||||
:class="
|
||||
cn(
|
||||
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-2xs leading-[14px] font-medium text-base-foreground',
|
||||
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] leading-[14px] font-medium text-base-foreground',
|
||||
badgeClass || '-top-1 -right-1'
|
||||
)
|
||||
"
|
||||
@@ -42,7 +42,7 @@
|
||||
</slot>
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label text-center text-2xs"
|
||||
class="side-bar-button-label text-center text-[10px]"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<template #alt-title>
|
||||
<span
|
||||
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
|
||||
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
|
||||
@@ -85,7 +85,7 @@ const modelDef = props.modelDef
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
margin: 5px;
|
||||
font-size: var(--text-2xs);
|
||||
font-size: 10px;
|
||||
}
|
||||
.model_preview_prefix {
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,51 +1,71 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
|
||||
error: boolean
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn()
|
||||
}))
|
||||
|
||||
describe('BaseThumbnail', () => {
|
||||
function renderThumbnail(
|
||||
props: Partial<ComponentProps<typeof BaseThumbnail>> = {}
|
||||
) {
|
||||
return render(BaseThumbnail, {
|
||||
props: props as ComponentProps<typeof BaseThumbnail>,
|
||||
const mountThumbnail = (props = {}, slots = {}) => {
|
||||
return mount(BaseThumbnail, {
|
||||
props,
|
||||
slots: {
|
||||
default: '<img src="/test.jpg" alt="test" />'
|
||||
default: '<img src="/test.jpg" alt="test" />',
|
||||
...slots
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
renderThumbnail()
|
||||
expect(screen.getByAltText('test')).toBeTruthy()
|
||||
const wrapper = mountThumbnail()
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applies hover zoom with correct style', () => {
|
||||
renderThumbnail({ isHovered: true })
|
||||
const contentDiv = screen.getByTestId('thumbnail-content')
|
||||
expect(contentDiv).toHaveStyle({ transform: 'scale(1.04)' })
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.attributes('style')).toContain('transform')
|
||||
expect(contentDiv.attributes('style')).toContain('scale')
|
||||
})
|
||||
|
||||
it('applies custom hover zoom value', () => {
|
||||
renderThumbnail({ hoverZoom: 10, isHovered: true })
|
||||
const contentDiv = screen.getByTestId('thumbnail-content')
|
||||
expect(contentDiv).toHaveStyle({ transform: 'scale(1.1)' })
|
||||
const wrapper = mountThumbnail({ hoverZoom: 10, isHovered: true })
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.attributes('style')).toContain('scale(1.1)')
|
||||
})
|
||||
|
||||
it('does not apply scale when not hovered', () => {
|
||||
renderThumbnail({ isHovered: false })
|
||||
const contentDiv = screen.getByTestId('thumbnail-content')
|
||||
expect(contentDiv).not.toHaveAttribute('style')
|
||||
const wrapper = mountThumbnail({ isHovered: false })
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.attributes('style')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
renderThumbnail()
|
||||
const img = screen.getByAltText('test')
|
||||
await fireEvent.error(img)
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'/assets/images/default-template.png'
|
||||
)
|
||||
const wrapper = mountThumbnail()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
// Manually set error since useEventListener is mocked
|
||||
vm.error = true
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('applies transition classes to content', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.classes()).toContain('transform-gpu')
|
||||
expect(contentDiv.classes()).toContain('transition-transform')
|
||||
expect(contentDiv.classes()).toContain('duration-1000')
|
||||
expect(contentDiv.classes()).toContain('ease-out')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div
|
||||
v-if="!error"
|
||||
ref="contentRef"
|
||||
data-testid="thumbnail-content"
|
||||
class="size-full transform-gpu transition-transform duration-1000 ease-out"
|
||||
:style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -37,7 +38,7 @@ vi.mock('firebase/auth', () => ({
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: vi.fn((store: Record<string, unknown>) => store)
|
||||
storeToRefs: vi.fn((store) => store)
|
||||
}))
|
||||
|
||||
// Mock the useFeatureFlags composable
|
||||
@@ -90,25 +91,13 @@ vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
default: defineComponent({
|
||||
default: {
|
||||
name: 'CurrentUserPopoverLegacyMock',
|
||||
emits: ['close'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h('div', [
|
||||
'Popover Content',
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'close-popover',
|
||||
onClick: () => emit('close')
|
||||
},
|
||||
'Close'
|
||||
)
|
||||
])
|
||||
}
|
||||
})
|
||||
render() {
|
||||
return h('div', 'Popover Content')
|
||||
},
|
||||
emits: ['close']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserButton', () => {
|
||||
@@ -121,66 +110,63 @@ describe('CurrentUserButton', () => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
|
||||
const { stubButton = true } = options ?? {}
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(CurrentUserButton, {
|
||||
return mount(CurrentUserButton, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
Popover: defineComponent({
|
||||
setup(_, { slots, expose }) {
|
||||
const shown = ref(false)
|
||||
expose({
|
||||
toggle: () => {
|
||||
shown.value = !shown.value
|
||||
},
|
||||
hide: () => {
|
||||
shown.value = false
|
||||
}
|
||||
})
|
||||
return () => (shown.value ? h('div', slots.default?.()) : null)
|
||||
// Use shallow mount for popover to make testing easier
|
||||
Popover: {
|
||||
template: '<div><slot></slot></div>',
|
||||
methods: {
|
||||
toggle: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}
|
||||
})
|
||||
},
|
||||
...(stubButton ? { Button: true } : {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
it('renders correctly when user is logged in', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Current user' })
|
||||
).toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles popover on button click', async () => {
|
||||
const { user } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const popoverToggleSpy = vi.fn()
|
||||
|
||||
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
|
||||
// Override the ref with a mock implementation
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.popover = { toggle: popoverToggleSpy }
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Current user' }))
|
||||
|
||||
expect(screen.getByText('Popover Content')).toBeInTheDocument()
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
expect(popoverToggleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides popover when closePopover is called', async () => {
|
||||
const { user } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Current user' }))
|
||||
expect(screen.getByText('Popover Content')).toBeInTheDocument()
|
||||
// Replace the popover.hide method with a spy
|
||||
const popoverHideSpy = vi.fn()
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.popover = { hide: popoverHideSpy }
|
||||
|
||||
await user.click(screen.getByTestId('close-popover'))
|
||||
// Directly call the closePopover method through the component instance
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.closePopover()
|
||||
|
||||
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows UserAvatar in personal workspace', () => {
|
||||
@@ -189,9 +175,9 @@ describe('CurrentUserButton', () => {
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
|
||||
|
||||
renderComponent()
|
||||
expect(screen.getByText('Avatar')).toBeInTheDocument()
|
||||
expect(screen.queryByText('WorkspaceProfilePic')).not.toBeInTheDocument()
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('Avatar')
|
||||
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
|
||||
})
|
||||
|
||||
it('shows WorkspaceProfilePic in team workspace', () => {
|
||||
@@ -201,8 +187,8 @@ describe('CurrentUserButton', () => {
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
|
||||
|
||||
renderComponent()
|
||||
expect(screen.getByText('WorkspaceProfilePic')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Avatar')).not.toBeInTheDocument()
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('WorkspaceProfilePic')
|
||||
expect(wrapper.html()).not.toContain('Avatar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,10 +61,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useAuthActions composable
|
||||
// Mock the useFirebaseAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
@@ -77,7 +77,7 @@ vi.mock('@/services/dialogService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the authStore with hoisted state for per-test manipulation
|
||||
// Mock the firebaseAuthStore with hoisted state for per-test manipulation
|
||||
const mockAuthStoreState = vi.hoisted(() => ({
|
||||
balance: {
|
||||
amount_micros: 100_000,
|
||||
@@ -91,8 +91,8 @@ const mockAuthStoreState = vi.hoisted(() => ({
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
|
||||
@@ -159,7 +159,7 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -168,7 +168,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -178,8 +178,8 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
<div
|
||||
v-else-if="badge.label"
|
||||
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
|
||||
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
|
||||
:class="labelClasses"
|
||||
>
|
||||
{{ badge.label }}
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
|
||||
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
|
||||
:class="labelClasses"
|
||||
>
|
||||
{{ badge.label }}
|
||||
@@ -68,7 +68,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
|
||||
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
|
||||
:class="labelClasses"
|
||||
>
|
||||
{{ badge.label }}
|
||||
@@ -87,7 +87,7 @@
|
||||
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
|
||||
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
|
||||
:class="labelClasses"
|
||||
>
|
||||
{{ badge.label }}
|
||||
@@ -115,7 +115,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
|
||||
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
|
||||
:class="labelClasses"
|
||||
>
|
||||
{{ badge.label }}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const WithLabel: Story = {
|
||||
},
|
||||
template: `
|
||||
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
|
||||
<label class="pointer-events-none absolute left-3 top-1.5 text-2xs text-muted-foreground z-10">
|
||||
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
|
||||
Prompt
|
||||
</label>
|
||||
<Textarea
|
||||
|
||||
@@ -3,11 +3,11 @@ import { computed, watch } from 'vue'
|
||||
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const authStore = useAuthStore()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const commandStore = useCommandStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { BillingPortalTargetTier } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
|
||||
import { usdToMicros } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
@@ -20,8 +20,8 @@ import { usdToMicros } from '@/utils/formatUtil'
|
||||
* All actions are wrapped with error handling.
|
||||
* @returns {Object} - Object containing all Firebase Auth actions
|
||||
*/
|
||||
export const useAuthActions = () => {
|
||||
const authStore = useAuthStore()
|
||||
export const useFirebaseAuthActions = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
|
||||
|
||||
@@ -70,8 +70,8 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
@@ -33,7 +33,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
showSubscriptionDialog: legacyShowSubscriptionDialog
|
||||
} = useSubscription()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
@@ -55,12 +55,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
|
||||
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const legacyBalance = authStore.balance
|
||||
const legacyBalance = firebaseAuthStore.balance
|
||||
if (!legacyBalance) return null
|
||||
|
||||
return {
|
||||
@@ -118,7 +118,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await authStore.fetchBalance()
|
||||
await firebaseAuthStore.fetchBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
|
||||
@@ -56,8 +56,8 @@ vi.mock('@/scripts/api', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore')
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({}))
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
|
||||
@@ -123,8 +123,8 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({}))
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
@@ -78,7 +78,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const authActions = useAuthActions()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -996,7 +996,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Sign Out',
|
||||
versionAdded: '1.18.1',
|
||||
function: async () => {
|
||||
await authActions.logout()
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -560,7 +560,7 @@ function drawDisconnectedPlaceholder(
|
||||
'#333'
|
||||
)
|
||||
const textColor = readDesignToken('--color-text-secondary', '#999')
|
||||
const fontSize = readDesignToken('--text-2xs', '11px')
|
||||
const fontSize = readDesignToken('--text-xxs', '11px')
|
||||
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
|
||||
|
||||
ctx.save()
|
||||
|
||||
@@ -347,20 +347,10 @@
|
||||
"share": "مشاركة",
|
||||
"workflowActions": "إجراءات سير العمل"
|
||||
},
|
||||
"builderFooter": {
|
||||
"opensAsApp": "فتح كـ {mode}",
|
||||
"opensAsGraph": "فتح كـ {mode}"
|
||||
},
|
||||
"builderMenu": {
|
||||
"enterAppMode": "الدخول إلى وضع التطبيق",
|
||||
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
|
||||
},
|
||||
"builderSave": {
|
||||
"successBody": "هل ترغب في عرضها الآن؟",
|
||||
"successBodyApp": "سيتم فتح سير العمل هذا في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.\n\nهل ترغب في عرضه الآن؟",
|
||||
"successBodyGraph": "سيتم فتح سير العمل هذا كـ رسم بياني للعقد.",
|
||||
"successTitle": "تم الحفظ بنجاح"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "تطبيق",
|
||||
"appDescription": "يفتح كتطبيق بشكل افتراضي",
|
||||
@@ -369,10 +359,18 @@
|
||||
"connectOutput": "توصيل مخرج",
|
||||
"connectOutputBody1": "يجب توصيل مخرج واحد على الأقل قبل حفظ التطبيق.",
|
||||
"connectOutputBody2": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
|
||||
"defaultModeAppliedAppBody": "سيفتح سير العمل هذا في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
|
||||
"defaultModeAppliedAppPrompt": "هل ترغب في عرضه الآن؟",
|
||||
"defaultModeAppliedGraphBody": "سيفتح سير العمل هذا كـمخطط عقد بشكل افتراضي من الآن فصاعدًا.",
|
||||
"defaultModeAppliedGraphPrompt": "هل ترغب في عرض التطبيق مع ذلك؟",
|
||||
"defaultModeAppliedTitle": "تم التعيين بنجاح",
|
||||
"defaultView": "تعيين العرض الافتراضي",
|
||||
"defaultViewDescription": "اختر كيفية الفتح",
|
||||
"defaultViewLabel": "افتراضيًا، سيتم فتح سير العمل هذا كـ:",
|
||||
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
|
||||
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
|
||||
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
|
||||
"filename": "اسم الملف",
|
||||
"exitToWorkflow": "الخروج إلى سير العمل",
|
||||
"inputs": "المدخلات",
|
||||
"inputsDescription": "اختر المدخلات",
|
||||
"label": "منشئ التطبيقات",
|
||||
@@ -380,7 +378,6 @@
|
||||
"nodeGraphDescription": "يفتح كرسم عقد بشكل افتراضي",
|
||||
"outputs": "المخرجات",
|
||||
"outputsDescription": "اختر المخرجات",
|
||||
"saveAs": "حفظ باسم",
|
||||
"switchToOutputs": "الانتقال إلى المخرجات",
|
||||
"viewApp": "عرض التطبيق"
|
||||
},
|
||||
@@ -901,15 +898,7 @@
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
"missingMedia": "بعض العقد تفتقد إلى مدخلات مطلوبة",
|
||||
"missingModels": "{count} نموذج مطلوب مفقود | {count} نماذج مطلوبة مفقودة",
|
||||
"missingNodes": "بعض العقد مفقودة وتحتاج إلى التثبيت",
|
||||
"seeErrors": "عرض الأخطاء",
|
||||
"showMissingMedia": "عرض المدخلات المفقودة",
|
||||
"showMissingModels": "عرض النماذج المفقودة",
|
||||
"showMissingNodes": "عرض العقد المفقودة",
|
||||
"showSwapNodes": "عرض العقد البديلة",
|
||||
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
|
||||
"seeErrors": "عرض الأخطاء"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "معالجة صور دفعة واحدة",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user