Merge branch 'main' into glary/website-cloud-nodes-mock-and-slugify
9
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,19 +106,12 @@ jobs:
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
|
||||
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source
|
||||
--ignore-errors source,unmapped
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
browser_tests/fixtures/helpers/TemplateHelper.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
|
||||
function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
}
|
||||
|
||||
function cloneIndex(
|
||||
index: readonly WorkflowTemplates[] | null
|
||||
): WorkflowTemplates[] | null {
|
||||
return index ? index.map((m) => structuredClone(m)) : null
|
||||
}
|
||||
|
||||
function addTemplates(
|
||||
config: TemplateConfig,
|
||||
templates: TemplateInfo[]
|
||||
): TemplateConfig {
|
||||
return { ...config, templates: [...config.templates, ...templates] }
|
||||
}
|
||||
|
||||
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: TemplateConfig = emptyConfig()
|
||||
) {
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
configure(...operators: TemplateOperator[]): void {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
const thumbnailHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
return cloneTemplates(this.templates)
|
||||
}
|
||||
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
page: Page,
|
||||
...operators: TemplateOperator[]
|
||||
): TemplateHelper {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new TemplateHelper(page, config)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||
169
browser_tests/fixtures/jobsRouteFixture.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type {
|
||||
JobStatus,
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const terminalJobStatuses = [
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
] as const satisfies readonly JobStatus[]
|
||||
const activeJobStatuses = [
|
||||
'in_progress',
|
||||
'pending'
|
||||
] as const satisfies readonly JobStatus[]
|
||||
const defaultJobsListLimit = 200
|
||||
const defaultScenarioHistoryLimit = 64
|
||||
const defaultJobsListOffset = 0
|
||||
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
interface JobsListRoute {
|
||||
statuses: readonly JobStatus[]
|
||||
jobs: readonly RawJobListItem[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
|
||||
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
|
||||
const requestedStatuses = new Set(
|
||||
url.searchParams.get('status')?.split(',') ?? []
|
||||
)
|
||||
|
||||
return (
|
||||
requestedStatuses.size === statuses.length &&
|
||||
statuses.every((status) => requestedStatuses.has(status))
|
||||
)
|
||||
}
|
||||
|
||||
function searchParamNumber(url: URL, name: string, fallback: number): number {
|
||||
const value = url.searchParams.get(name)
|
||||
return value === null ? fallback : Number(value)
|
||||
}
|
||||
|
||||
function hasJobsListPageParams(
|
||||
url: URL,
|
||||
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
|
||||
): boolean {
|
||||
return (
|
||||
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
|
||||
(limit ?? defaultJobsListLimit) &&
|
||||
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
|
||||
(offset ?? defaultJobsListOffset)
|
||||
)
|
||||
}
|
||||
|
||||
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
|
||||
return (
|
||||
url.pathname.endsWith('/api/jobs') &&
|
||||
hasExactStatuses(url, route.statuses) &&
|
||||
hasJobsListPageParams(url, route)
|
||||
)
|
||||
}
|
||||
|
||||
function createJobsListResponse({
|
||||
jobs,
|
||||
limit = defaultJobsListLimit,
|
||||
offset = defaultJobsListOffset
|
||||
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
|
||||
const pageJobs = jobs.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
jobs: pageJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: jobs.length,
|
||||
has_more: offset + pageJobs.length < jobs.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouteMockJob({
|
||||
id,
|
||||
...overrides
|
||||
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
|
||||
return {
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: defaultRouteMockJobTimestamp,
|
||||
execution_start_time: defaultRouteMockJobTimestamp,
|
||||
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
jobs: readonly RawJobListItem[],
|
||||
limit = defaultJobsListLimit
|
||||
): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: terminalJobStatuses,
|
||||
jobs,
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: activeJobStatuses,
|
||||
jobs
|
||||
})
|
||||
}
|
||||
|
||||
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
|
||||
if (history) {
|
||||
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
|
||||
}
|
||||
if (queue) {
|
||||
await this.mockJobsQueue(queue)
|
||||
}
|
||||
}
|
||||
|
||||
async mockJobsList(route: JobsListRoute): Promise<void> {
|
||||
const response = createJobsListResponse(route)
|
||||
|
||||
await this.page.route(
|
||||
(url) => isJobsListRequest(url, route),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await requestRoute.fulfill({ json: response })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const jobsRouteFixture = base.extend<{
|
||||
jobsRoutes: JobsRouteMocker
|
||||
}>({
|
||||
jobsRoutes: async ({ page }, use) => {
|
||||
await use(new JobsRouteMocker(page))
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
}
|
||||
})
|
||||
16
browser_tests/fixtures/templateApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
|
||||
export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobRecords(
|
||||
listItems: readonly JobEntry[]
|
||||
): MockJobRecord[] {
|
||||
return listItems.map(createMockJobRecord)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -8,15 +8,15 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
@@ -213,9 +213,9 @@ async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function outputHistoryJobs() {
|
||||
return createMockJobRecords([
|
||||
createMockJob({
|
||||
function outputHistoryJobs(): RawJobListItem[] {
|
||||
return [
|
||||
createRouteMockJob({
|
||||
id: 'history-output-image',
|
||||
preview_output: {
|
||||
filename: 'ComfyUI_00001_.png',
|
||||
@@ -225,7 +225,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'history-output-video',
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
@@ -235,7 +235,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'video'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'history-output-audio',
|
||||
preview_output: {
|
||||
filename: 'sound.wav',
|
||||
@@ -245,7 +245,7 @@ function outputHistoryJobs() {
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
ossTest.describe(
|
||||
@@ -258,8 +258,9 @@ ossTest.describe(
|
||||
|
||||
ossTest(
|
||||
'resolves annotated output media from job history',
|
||||
async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(outputHistoryJobs())
|
||||
async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_output_annotations'
|
||||
|
||||
@@ -1,56 +1,54 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
create_time: mockJobTimestamp - 60_000,
|
||||
execution_start_time: mockJobTimestamp - 60_000,
|
||||
execution_end_time: mockJobTimestamp - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
create_time: mockJobTimestamp - 120_000,
|
||||
execution_start_time: mockJobTimestamp - 120_000,
|
||||
execution_end_time: mockJobTimestamp - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
create_time: mockJobTimestamp - 30_000,
|
||||
execution_start_time: mockJobTimestamp - 30_000,
|
||||
execution_end_time: mockJobTimestamp - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createMockJob({
|
||||
createRouteMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
create_time: mockJobTimestamp - 180_000,
|
||||
execution_start_time: mockJobTimestamp - 180_000,
|
||||
execution_end_time: mockJobTimestamp - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -26,53 +26,37 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
await templateApi.mockThumbnails()
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -86,45 +70,38 @@ test.describe(
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -144,36 +121,29 @@ test.describe(
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -200,28 +170,21 @@ test.describe(
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -234,39 +197,32 @@ test.describe(
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
templateApi
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.7",
|
||||
"version": "1.45.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
723
pnpm-lock.yaml
generated
@@ -36,7 +36,7 @@ catalog:
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tailwindcss/vite': ^4.3.0
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
@@ -112,7 +112,7 @@ catalog:
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss: ^4.3.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
|
||||
@@ -16,14 +16,6 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/dialog', () => ({
|
||||
default: {
|
||||
name: 'Dialog',
|
||||
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
|
||||
props: ['visible']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
@@ -32,8 +24,29 @@ vi.mock('primevue/selectbutton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: { name: 'Divider', template: '<hr />' }
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: { name: 'Dialog', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
|
||||
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
|
||||
default: { name: 'DialogOverlay', template: '<div />' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
|
||||
default: { name: 'DialogContent', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
|
||||
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({
|
||||
default: { name: 'DialogFooter', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
|
||||
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
|
||||
default: { name: 'DialogClose', template: '<button />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
|
||||
|
||||
@@ -1,72 +1,111 @@
|
||||
<template>
|
||||
<Dialog v-model:visible="visible" :header="$t('g.customizeFolder')">
|
||||
<div class="p-fluid">
|
||||
<div class="field icon-field">
|
||||
<label for="icon">{{ $t('g.icon') }}</label>
|
||||
<SelectButton
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
<Dialog v-model:open="visible" :modal="false">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{ $t('g.customizeFolder') }}
|
||||
</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-icon" class="text-sm font-medium">
|
||||
{{ $t('g.icon') }}
|
||||
</label>
|
||||
<SelectButton
|
||||
id="customization-icon"
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-color" class="text-sm font-medium">
|
||||
{{ $t('g.color') }}
|
||||
</label>
|
||||
<ColorCustomizationSelector
|
||||
id="customization-color"
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div class="field color-field">
|
||||
<label for="color">{{ $t('g.color') }}</label>
|
||||
<ColorCustomizationSelector
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
const { initialIcon, initialColor } = defineProps<{
|
||||
initialIcon?: string
|
||||
initialColor?: string
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', icon: string, color: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const titleId = useId()
|
||||
|
||||
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
|
||||
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
|
||||
// overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
|
||||
|
||||
function onPointerDownOutside(
|
||||
event: CustomEvent<{ originalEvent: PointerEvent }>
|
||||
) {
|
||||
const target = event.detail.originalEvent.target
|
||||
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
@@ -95,30 +134,22 @@ const defaultIcon = iconOptions.find(
|
||||
)
|
||||
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
|
||||
|
||||
const resetCustomization = () => {
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
|
||||
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
const confirmCustomization = () => {
|
||||
emit('confirm', selectedIcon.value.value, finalColor.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue: boolean) => {
|
||||
visible,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
resetCustomization()
|
||||
}
|
||||
@@ -135,10 +166,4 @@ watch(
|
||||
.p-selectbutton .p-button .pi {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] -translate-1/2 rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
|
||||
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchInput"
|
||||
:searcher="applySearchQuery"
|
||||
:debounce-ms="400"
|
||||
@@ -412,7 +412,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
|
||||
<pre class="wrap-break-word whitespace-pre-wrap">{{
|
||||
reportContent
|
||||
}}</pre>
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
</template>
|
||||
<div class="flex justify-end gap-4">
|
||||
<FindIssueButton
|
||||
@@ -62,8 +62,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-current"
|
||||
class="pointer-events-none size-6.25 fill-current"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
@@ -26,7 +26,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
@@ -44,7 +44,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="-9 -7 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<g transform="scale(-1, 1)">
|
||||
<path
|
||||
@@ -76,7 +76,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
|
||||
@@ -92,7 +92,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
|
||||
class="pointer-events-none size-6.25 fill-(--input-text)"
|
||||
>
|
||||
<path
|
||||
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
|
||||
|
||||
@@ -370,7 +370,7 @@ function handleTitleCancel() {
|
||||
</section>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div class="flex-1 scrollbar-thin overflow-y-auto">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
FormSearchInput: {
|
||||
AsyncSearchInput: {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && tabErrorGroups.length > 1"
|
||||
@@ -260,7 +260,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
@@ -119,7 +119,7 @@ function onCollapseUpdate() {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
|
||||
@@ -7,7 +7,7 @@ import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseTog
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
@@ -78,7 +78,7 @@ async function searcher(query: string) {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
@@ -122,7 +122,7 @@ const advancedLabel = computed(() => {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -217,7 +217,7 @@ const label = computed(() => {
|
||||
<div
|
||||
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsList"
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -226,7 +226,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
|
||||
<div class="flex gap-2 border-b border-interface-stroke px-4 pt-1 pb-4">
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
<AsyncSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
class="mx-[-2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { defineComponent, ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from './FormSearchInput.vue'
|
||||
import AsyncSearchInput from './AsyncSearchInput.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -20,7 +20,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
type Searcher = NonNullable<ComponentProps<typeof FormSearchInput>['searcher']>
|
||||
type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
|
||||
|
||||
function renderSearch(
|
||||
initialQuery: string = '',
|
||||
@@ -30,9 +30,9 @@ function renderSearch(
|
||||
const query = ref(initialQuery)
|
||||
const key = updateKey
|
||||
const Harness = defineComponent({
|
||||
components: { FormSearchInput },
|
||||
components: { AsyncSearchInput },
|
||||
setup: () => ({ query, searcher, key }),
|
||||
template: `<FormSearchInput
|
||||
template: `<AsyncSearchInput
|
||||
v-model="query"
|
||||
:searcher="searcher"
|
||||
:update-key="key"
|
||||
@@ -42,7 +42,7 @@ function renderSearch(
|
||||
return { ...utils, query, key }
|
||||
}
|
||||
|
||||
describe('FormSearchInput', () => {
|
||||
describe('AsyncSearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
@@ -47,7 +47,7 @@ watch(
|
||||
|
||||
searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
|
||||
.catch((error) => {
|
||||
console.error('[SidePanelSearch] searcher failed', error)
|
||||
console.error('[AsyncSearchInput] searcher failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
@@ -23,7 +23,7 @@ defineExpose({
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
'flex min-h-16 w-full scrollbar-gutter-stable rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
|
||||
@@ -23,8 +23,19 @@ import type { CanvasPointerEvent } from './types/events'
|
||||
* - {@link LGraphCanvas.processMouseUp}
|
||||
*/
|
||||
export class CanvasPointer {
|
||||
/** Maximum time in milliseconds to ignore click drift */
|
||||
static bufferTime = 150
|
||||
/**
|
||||
* Maximum time in milliseconds to ignore click drift.
|
||||
*
|
||||
* This is the upper bound on how long after pointerdown the system will wait
|
||||
* before deciding "this is a drag, not a click" when the pointer hasn't moved
|
||||
* past {@link maxClickDrift}. Keep this short — drags should feel instant.
|
||||
* Disambiguation between click and drag is primarily handled by distance
|
||||
* ({@link maxClickDrift}); this time threshold only matters when the user
|
||||
* holds the pointer still then releases. ~2 frames at 60fps is plenty.
|
||||
*
|
||||
* Overridden at runtime by the `Comfy.Pointer.ClickBufferTime` user setting.
|
||||
*/
|
||||
static bufferTime = 32
|
||||
|
||||
/** Maximum gap between pointerup and pointerdown events to be considered as a double click */
|
||||
static doubleClickTime = 300
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
|
||||
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
|
||||
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
"promptExecutionError": "فشل تنفيذ الطلب",
|
||||
"queueOpenWorkflowFailedTitle": "فشل في فتح سير العمل"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
||||
"comfyPackageOutdated": "إصدار {name} المثبت ({installedVersion}) أقل من الإصدار المطلوب ({requiredVersion}).",
|
||||
"comingSoon": "قريباً",
|
||||
"command": "أمر",
|
||||
"commandProhibited": "الأمر {command} محظور. يرجى التواصل مع المسؤول لمزيد من المعلومات.",
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "Pointer click drift delay",
|
||||
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking."
|
||||
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty."
|
||||
},
|
||||
"Comfy_Pointer_ClickDrift": {
|
||||
"name": "Pointer click drift (maximum distance)",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "Esto puede deberse al siguiente script",
|
||||
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
|
||||
"noStackTrace": "No hay seguimiento de pila disponible",
|
||||
"promptExecutionError": "La ejecución del prompt falló"
|
||||
"promptExecutionError": "La ejecución del prompt falló",
|
||||
"queueOpenWorkflowFailedTitle": "No se pudo abrir el flujo de trabajo"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo de ComfyOrg",
|
||||
"comfyPackageOutdated": "La versión instalada de {name} ({installedVersion}) es inferior a la versión requerida ({requiredVersion}).",
|
||||
"comingSoon": "Próximamente",
|
||||
"command": "Comando",
|
||||
"commandProhibited": "El comando {command} está prohibido. Contacta a un administrador para más información.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
|
||||
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد دادههای workflow متوقف شد",
|
||||
"noStackTrace": "هیچ stacktraceی موجود نیست",
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
|
||||
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
|
||||
"queueOpenWorkflowFailedTitle": "باز کردن workflow با خطا مواجه شد"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطا",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
|
||||
"comfyPackageOutdated": "نسخه نصبشده {name} با شماره {installedVersion} پایینتر از نسخه مورد نیاز {requiredVersion} است.",
|
||||
"comingSoon": "بهزودی",
|
||||
"command": "دستور",
|
||||
"commandProhibited": "دستور {command} مجاز نیست. برای اطلاعات بیشتر با مدیر تماس بگیرید.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "Cela peut être dû au script suivant",
|
||||
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
|
||||
"noStackTrace": "Aucune trace de pile disponible",
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué"
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué",
|
||||
"queueOpenWorkflowFailedTitle": "Échec de l'ouverture du workflow"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERREUR | {count} ERREURS",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo ComfyOrg",
|
||||
"comfyPackageOutdated": "La version installée de {name} ({installedVersion}) est inférieure à la version requise ({requiredVersion}).",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
"command": "Commande",
|
||||
"commandProhibited": "La commande {command} est interdite. Contactez un administrateur pour plus d'informations.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
|
||||
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
|
||||
"noStackTrace": "スタックトレースは利用できません",
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました"
|
||||
"promptExecutionError": "プロンプトの実行に失敗しました",
|
||||
"queueOpenWorkflowFailedTitle": "ワークフローのオープンに失敗しました"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 件のエラー",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrgロゴ",
|
||||
"comfyPackageOutdated": "インストールされている{name}のバージョン{installedVersion}は、必要なバージョン{requiredVersion}よりも低いです。",
|
||||
"comingSoon": "近日公開",
|
||||
"command": "コマンド",
|
||||
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
|
||||
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
|
||||
"noStackTrace": "스택 추적을 사용할 수 없습니다",
|
||||
"promptExecutionError": "프롬프트 실행 실패"
|
||||
"promptExecutionError": "프롬프트 실행 실패",
|
||||
"queueOpenWorkflowFailedTitle": "워크플로우 열기에 실패했습니다"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}개 오류",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 로고",
|
||||
"comfyPackageOutdated": "설치된 {name} 버전 {installedVersion}이(가) 필요한 버전 {requiredVersion}보다 낮습니다.",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
"command": "명령",
|
||||
"commandProhibited": "{command}는 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "Isso pode ser devido ao seguinte script",
|
||||
"loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho",
|
||||
"noStackTrace": "Nenhum stacktrace disponível",
|
||||
"promptExecutionError": "Falha na execução do prompt"
|
||||
"promptExecutionError": "Falha na execução do prompt",
|
||||
"queueOpenWorkflowFailedTitle": "Falha ao abrir o workflow"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRO | {count} ERROS",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Logo do ComfyOrg",
|
||||
"comfyPackageOutdated": "A versão instalada de {name} ({installedVersion}) é inferior à versão necessária ({requiredVersion}).",
|
||||
"comingSoon": "Em breve",
|
||||
"command": "Comando",
|
||||
"commandProhibited": "O comando {command} é proibido. Entre em contato com um administrador para mais informações.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "Это может быть связано со следующим скриптом",
|
||||
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
|
||||
"noStackTrace": "Стек вызовов недоступен",
|
||||
"promptExecutionError": "Ошибка выполнения запроса"
|
||||
"promptExecutionError": "Ошибка выполнения запроса",
|
||||
"queueOpenWorkflowFailedTitle": "Не удалось открыть рабочий процесс"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "Логотип ComfyOrg",
|
||||
"comfyPackageOutdated": "Установленная версия {name} ({installedVersion}) ниже требуемой версии ({requiredVersion}).",
|
||||
"comingSoon": "Скоро будет",
|
||||
"command": "Команда",
|
||||
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir",
|
||||
"loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi",
|
||||
"noStackTrace": "Yığın izi mevcut değil",
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu"
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu",
|
||||
"queueOpenWorkflowFailedTitle": "İş Akışı Açılamadı"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} HATA | {count} HATA",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg Logosu",
|
||||
"comfyPackageOutdated": "Yüklü {name} sürümü ({installedVersion}), gerekli sürümden ({requiredVersion}) daha eski.",
|
||||
"comingSoon": "Çok Yakında",
|
||||
"command": "Komut",
|
||||
"commandProhibited": "{command} komutu yasak. Daha fazla bilgi için bir yöneticiyle iletişime geçin.",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "這可能是由於以下指令碼所致",
|
||||
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
|
||||
"noStackTrace": "沒有可用的堆疊追蹤",
|
||||
"promptExecutionError": "提示執行失敗"
|
||||
"promptExecutionError": "提示執行失敗",
|
||||
"queueOpenWorkflowFailedTitle": "開啟工作流程失敗"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} 個錯誤",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 標誌",
|
||||
"comfyPackageOutdated": "已安裝的 {name} 版本 {installedVersion} 低於所需版本 {requiredVersion}。",
|
||||
"comingSoon": "即將推出",
|
||||
"command": "指令",
|
||||
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",
|
||||
|
||||
@@ -913,7 +913,8 @@
|
||||
"extensionFileHint": "这可能是由于以下脚本",
|
||||
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
|
||||
"noStackTrace": "无可用堆栈跟踪",
|
||||
"promptExecutionError": "提示执行失败"
|
||||
"promptExecutionError": "提示执行失败",
|
||||
"queueOpenWorkflowFailedTitle": "打开工作流失败"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count}个错误",
|
||||
@@ -1041,6 +1042,7 @@
|
||||
"comfy": "舒适",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "ComfyOrg 徽标",
|
||||
"comfyPackageOutdated": "已安装的 {name} 版本 {installedVersion} 低于所需版本 {requiredVersion}。",
|
||||
"comingSoon": "即将推出",
|
||||
"command": "指令",
|
||||
"commandProhibited": "命令 {command} 被禁止。请联系管理员了解更多信息。",
|
||||
|
||||
@@ -1,48 +1,51 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="isVisible"
|
||||
modal
|
||||
:closable="false"
|
||||
:close-on-escape="false"
|
||||
:dismissable-mask="true"
|
||||
:pt="{
|
||||
root: { class: 'video-help-dialog' },
|
||||
header: { class: '!hidden' },
|
||||
content: { class: '!p-0' },
|
||||
mask: { class: '!bg-black/70' }
|
||||
}"
|
||||
:style="{ width: '90vw' }"
|
||||
>
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="absolute top-4 right-6 z-10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="isVisible = false"
|
||||
>
|
||||
<i class="pi pi-times text-sm" />
|
||||
</Button>
|
||||
<video
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
<Dialog v-model:open="isVisible">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="bg-black/70" />
|
||||
<DialogContent
|
||||
size="full"
|
||||
class="w-[90vw] border-0 bg-transparent p-0 shadow-none"
|
||||
:aria-label="ariaLabel"
|
||||
class="w-full rounded-lg"
|
||||
:src="videoUrl"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
>
|
||||
{{ $t('g.videoFailedToLoad') }}
|
||||
</video>
|
||||
</div>
|
||||
<VisuallyHidden as-child>
|
||||
<DialogTitle>{{ ariaLabel }}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="absolute top-4 right-6 z-10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="isVisible = false"
|
||||
>
|
||||
<i class="pi pi-times text-sm" />
|
||||
</Button>
|
||||
<video
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
:aria-label="ariaLabel"
|
||||
class="w-full rounded-lg"
|
||||
:src="videoUrl"
|
||||
>
|
||||
{{ $t('g.videoFailedToLoad') }}
|
||||
</video>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { onWatcherCleanup, watch } from 'vue'
|
||||
import { VisuallyHidden } from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
|
||||
const isVisible = defineModel<boolean>({ required: true })
|
||||
|
||||
@@ -51,27 +54,13 @@ const { videoUrl, ariaLabel = 'Help video' } = defineProps<{
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopImmediatePropagation()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
isVisible.value = false
|
||||
}
|
||||
// The dialog mounts inside other dialogs (e.g. UploadModelFooter inside an
|
||||
// asset modal). Reka's Escape handling bubbles to the parent dialog and would
|
||||
// close it as well. Stop propagation so only this dialog closes, and prevent
|
||||
// Reka's default auto-dismiss so the close path stays solely under the model.
|
||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
// Add listener with capture phase to intercept before parent dialogs
|
||||
// Only active when dialog is visible
|
||||
watch(
|
||||
isVisible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
const stop = useEventListener(document, 'keydown', handleEscapeKey, {
|
||||
capture: true
|
||||
})
|
||||
onWatcherCleanup(stop)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
rows="3"
|
||||
:class="
|
||||
cn(
|
||||
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
|
||||
'w-full resize-y scrollbar-gutter-stable rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
|
||||
isImmutable && 'cursor-not-allowed'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
|
||||
class="ml-[-20%] h-full min-w-5/4 object-cover p-0"
|
||||
>
|
||||
<source
|
||||
src="/assets/images/cloud-subscription.webm"
|
||||
|
||||
98
src/platform/secrets/components/SecretFormDialog.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SecretFormDialog from './SecretFormDialog.vue'
|
||||
|
||||
vi.mock('../composables/useSecretForm', () => ({
|
||||
useSecretForm: () => ({
|
||||
form: { provider: '', name: '', secretValue: '' },
|
||||
errors: {},
|
||||
loading: false,
|
||||
apiError: '',
|
||||
providerOptions: [],
|
||||
handleSubmit: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/inputtext', () => ({
|
||||
default: { name: 'InputText', template: '<input />' }
|
||||
}))
|
||||
vi.mock('primevue/password', () => ({
|
||||
default: { name: 'Password', template: '<input type="password" />' }
|
||||
}))
|
||||
|
||||
let capturedPointerDownOutside: ((event: Event) => void) | null = null
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: { name: 'Button', template: '<button><slot /></button>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/Select.vue', () => ({
|
||||
default: { name: 'Select', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', () => ({
|
||||
default: { name: 'SelectContent', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: { name: 'SelectItem', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: { name: 'Dialog', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
|
||||
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
|
||||
default: { name: 'DialogOverlay', template: '<div />' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'DialogContent',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs }) {
|
||||
const onPointerDownOutside = (attrs as Record<string, unknown>)[
|
||||
'onPointerDownOutside'
|
||||
] as ((event: Event) => void) | undefined
|
||||
capturedPointerDownOutside = onPointerDownOutside ?? null
|
||||
},
|
||||
template: '<div data-testid="dialog-content"><slot /></div>'
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
|
||||
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
|
||||
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
|
||||
default: { name: 'DialogClose', template: '<button />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
describe('SecretFormDialog', () => {
|
||||
beforeEach(() => {
|
||||
capturedPointerDownOutside = null
|
||||
})
|
||||
|
||||
it('prevents backdrop pointer-down-outside from closing the dialog', () => {
|
||||
render(SecretFormDialog, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { visible: true }
|
||||
})
|
||||
|
||||
expect(capturedPointerDownOutside).not.toBeNull()
|
||||
const event = new CustomEvent('pointerDownOutside', { cancelable: true })
|
||||
capturedPointerDownOutside!(event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +1,130 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="
|
||||
mode === 'create' ? $t('secrets.addSecret') : $t('secrets.editSecret')
|
||||
"
|
||||
modal
|
||||
class="w-full max-w-md"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-provider" class="text-sm font-medium">
|
||||
{{ $t('secrets.provider') }}
|
||||
</label>
|
||||
<Select v-model="form.provider" :disabled="mode === 'edit'">
|
||||
<SelectTrigger id="secret-provider" class="w-full" autofocus>
|
||||
<SelectValue :placeholder="$t('g.none')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent disable-portal>
|
||||
<SelectItem
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value || 'none'"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<small v-if="errors.provider" class="text-red-500">
|
||||
{{ errors.provider }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-name" class="text-sm font-medium">
|
||||
{{ $t('secrets.name') }}
|
||||
</label>
|
||||
<InputText
|
||||
id="secret-name"
|
||||
v-model="form.name"
|
||||
:placeholder="$t('secrets.namePlaceholder')"
|
||||
:class="{ 'p-invalid': errors.name }"
|
||||
/>
|
||||
<small v-if="errors.name" class="text-red-500">{{ errors.name }}</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-value" class="text-sm font-medium">
|
||||
{{ $t('secrets.secretValue') }}
|
||||
</label>
|
||||
<Password
|
||||
id="secret-value"
|
||||
v-model="form.secretValue"
|
||||
:placeholder="
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValuePlaceholderEdit')
|
||||
: $t('secrets.secretValuePlaceholder')
|
||||
"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
:class="{ 'p-invalid': errors.secretValue }"
|
||||
/>
|
||||
<small v-if="errors.secretValue" class="text-red-500">
|
||||
{{ errors.secretValue }}
|
||||
</small>
|
||||
<small v-else class="text-muted">
|
||||
{{
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValueHintEdit')
|
||||
: $t('secrets.secretValueHint')
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<span v-if="apiError" class="text-destructive text-sm">
|
||||
{{ apiError }}
|
||||
</span>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
@click="visible = false"
|
||||
<Dialog v-model:open="visible">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside.prevent
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{
|
||||
mode === 'create'
|
||||
? $t('secrets.addSecret')
|
||||
: $t('secrets.editSecret')
|
||||
}}
|
||||
</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="flex flex-col gap-4 px-4 py-2"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" tabindex="0" :loading="loading">
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-provider" class="text-sm font-medium">
|
||||
{{ $t('secrets.provider') }}
|
||||
</label>
|
||||
<Select v-model="form.provider" :disabled="mode === 'edit'">
|
||||
<SelectTrigger id="secret-provider" class="w-full" autofocus>
|
||||
<SelectValue :placeholder="$t('g.none')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent disable-portal>
|
||||
<SelectItem
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value || 'none'"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<small v-if="errors.provider" class="text-red-500">
|
||||
{{ errors.provider }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-name" class="text-sm font-medium">
|
||||
{{ $t('secrets.name') }}
|
||||
</label>
|
||||
<InputText
|
||||
id="secret-name"
|
||||
v-model="form.name"
|
||||
:placeholder="$t('secrets.namePlaceholder')"
|
||||
:class="{ 'p-invalid': errors.name }"
|
||||
/>
|
||||
<small v-if="errors.name" class="text-red-500">
|
||||
{{ errors.name }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="secret-value" class="text-sm font-medium">
|
||||
{{ $t('secrets.secretValue') }}
|
||||
</label>
|
||||
<Password
|
||||
id="secret-value"
|
||||
v-model="form.secretValue"
|
||||
:placeholder="
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValuePlaceholderEdit')
|
||||
: $t('secrets.secretValuePlaceholder')
|
||||
"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
:class="{ 'p-invalid': errors.secretValue }"
|
||||
/>
|
||||
<small v-if="errors.secretValue" class="text-red-500">
|
||||
{{ errors.secretValue }}
|
||||
</small>
|
||||
<small v-else class="text-muted">
|
||||
{{
|
||||
mode === 'edit'
|
||||
? $t('secrets.secretValueHintEdit')
|
||||
: $t('secrets.secretValueHint')
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<span v-if="apiError" class="text-destructive text-sm">
|
||||
{{ apiError }}
|
||||
</span>
|
||||
|
||||
<div class="flex justify-end gap-2 py-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
@click="visible = false"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" tabindex="0" :loading="loading">
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import { useId } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
@@ -126,6 +150,8 @@ const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const titleId = useId()
|
||||
|
||||
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
|
||||
useSecretForm({
|
||||
mode,
|
||||
|
||||
@@ -803,16 +803,17 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
category: ['LiteGraph', 'Pointer', 'ClickBufferTime'],
|
||||
name: 'Pointer click drift delay',
|
||||
tooltip:
|
||||
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.',
|
||||
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty.',
|
||||
experimental: true,
|
||||
type: 'slider',
|
||||
attrs: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 25
|
||||
step: 1
|
||||
},
|
||||
defaultValue: 150,
|
||||
versionAdded: '1.4.3'
|
||||
defaultValue: 32,
|
||||
versionAdded: '1.4.3',
|
||||
versionModified: '1.44.19'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Pointer.DoubleClickTime',
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
cn(
|
||||
'pointer-events-none absolute z-0 border-3 outline-none',
|
||||
selectionShapeClass,
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing'
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import FormSearchInput from '../FormSearchInput.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -99,7 +99,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
|
||||
<template>
|
||||
<div class="text-secondary flex gap-2 px-4">
|
||||
<FormSearchInput
|
||||
<AsyncSearchInput
|
||||
v-model="searchQuery"
|
||||
autofocus
|
||||
:class="
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Phase 1 dialog migration regression net: when `dialogService.prompt()`,
|
||||
* `dialogService.confirm()`, or `dialogService.showBillingComingSoonDialog()`
|
||||
* is invoked, the dialog stack item must carry `renderer: 'reka'`. Catches
|
||||
* accidental reverts of the Reka renderer flip.
|
||||
* Dialog migration regression net: when callers in `dialogService` open a
|
||||
* Reka-migrated dialog, the dialog stack item must carry `renderer: 'reka'`.
|
||||
* Catches accidental reverts of the Reka renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -34,7 +33,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('dialogService Reka renderer opt-in (Phase 1)', () => {
|
||||
describe('dialogService Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
@@ -60,4 +59,24 @@ describe('dialogService Reka renderer opt-in (Phase 1)', () => {
|
||||
expect(args.dialogComponentProps.size).toBe('sm')
|
||||
expect(args.dialogComponentProps.contentClass).toBe('max-w-[360px]')
|
||||
})
|
||||
|
||||
it("showExecutionErrorDialog() sets renderer 'reka' and size 'lg'", () => {
|
||||
useDialogService().showExecutionErrorDialog({
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'boom',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
traceback: ['line 1', 'line 2']
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
|
||||
it("showErrorDialog() sets renderer 'reka' and size 'lg'", () => {
|
||||
useDialogService().showErrorDialog(new Error('boom'))
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,6 +99,8 @@ export const useDialogService = () => {
|
||||
component: ErrorDialogContent,
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed'
|
||||
@@ -163,6 +165,8 @@ export const useDialogService = () => {
|
||||
component: ErrorDialogContent,
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed'
|
||||
|
||||