mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
7 Commits
perf/test-
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6668e1bb6 | ||
|
|
5fba682609 | ||
|
|
437f41c553 | ||
|
|
975393b48b | ||
|
|
a44fa1fdd5 | ||
|
|
cc3acebceb | ||
|
|
23c22e4c52 |
2
.github/workflows/pr-report.yaml
vendored
2
.github/workflows/pr-report.yaml
vendored
@@ -180,7 +180,7 @@ jobs:
|
||||
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
|
||||
git fetch origin perf-data --depth=1
|
||||
mkdir -p temp/perf-history
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
|
||||
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
|
||||
done
|
||||
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
|
||||
|
||||
2
apps/website/.gitignore
vendored
Normal file
2
apps/website/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
.astro/
|
||||
24
apps/website/astro.config.ts
Normal file
24
apps/website/astro.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
integrations: [vue()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
build: {
|
||||
assetsPrefix: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
},
|
||||
i18n: {
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
})
|
||||
80
apps/website/package.json
Normal file
80
apps/website/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@comfyorg/website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/website/public/fonts/inter-latin-italic.woff2
Normal file
BIN
apps/website/public/fonts/inter-latin-italic.woff2
Normal file
Binary file not shown.
BIN
apps/website/public/fonts/inter-latin-normal.woff2
Normal file
BIN
apps/website/public/fonts/inter-latin-normal.woff2
Normal file
Binary file not shown.
1
apps/website/src/env.d.ts
vendored
Normal file
1
apps/website/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
2
apps/website/src/styles/global.css
Normal file
2
apps/website/src/styles/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
9
apps/website/tsconfig.json
Normal file
9
apps/website/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -190,6 +191,7 @@ export class PerformanceHelper {
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize'),
|
||||
heapUsedBytes: after.JSHeapUsedSize,
|
||||
domNodes: delta('Nodes'),
|
||||
jsHeapTotalBytes: delta('JSHeapTotalSize'),
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
|
||||
@@ -27,6 +27,17 @@ const config: KnipConfig = {
|
||||
},
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'apps/website': {
|
||||
entry: [
|
||||
'src/pages/**/*.astro',
|
||||
'src/layouts/**/*.astro',
|
||||
'src/components/**/*.vue',
|
||||
'src/styles/global.css',
|
||||
'astro.config.ts'
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
|
||||
46
packages/design-system/src/css/base.css
Normal file
46
packages/design-system/src/css/base.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Design System Base — Brand tokens + fonts only.
|
||||
* For marketing sites that don't use PrimeVue or the node editor.
|
||||
* Import the full style.css instead for the desktop app.
|
||||
*/
|
||||
|
||||
@import './fonts.css';
|
||||
|
||||
@theme {
|
||||
/* Font Families */
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
|
||||
/* Palette Colors */
|
||||
--color-charcoal-100: #55565e;
|
||||
--color-charcoal-200: #494a50;
|
||||
--color-charcoal-300: #3c3d42;
|
||||
--color-charcoal-400: #313235;
|
||||
--color-charcoal-500: #2d2e32;
|
||||
--color-charcoal-600: #262729;
|
||||
--color-charcoal-700: #202121;
|
||||
--color-charcoal-800: #171718;
|
||||
|
||||
--color-neutral-550: #636363;
|
||||
|
||||
--color-ash-300: #bbbbbb;
|
||||
--color-ash-500: #828282;
|
||||
--color-ash-800: #444444;
|
||||
|
||||
--color-smoke-100: #f3f3f3;
|
||||
--color-smoke-200: #e9e9e9;
|
||||
--color-smoke-300: #e1e1e1;
|
||||
--color-smoke-400: #d9d9d9;
|
||||
--color-smoke-500: #c5c5c5;
|
||||
--color-smoke-600: #b4b4b4;
|
||||
--color-smoke-700: #a0a0a0;
|
||||
--color-smoke-800: #8a8a8a;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
/* Brand Colors */
|
||||
--color-electric-400: #f0ff41;
|
||||
--color-sapphire-700: #172dd7;
|
||||
--color-brand-yellow: var(--color-electric-400);
|
||||
--color-brand-blue: var(--color-sapphire-700);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
AssetDownloadResponse,
|
||||
AssetInfo,
|
||||
AssetMetadataResponse,
|
||||
AssetTagHistogramResponse,
|
||||
AssetUpdated,
|
||||
@@ -38,6 +39,11 @@ export type {
|
||||
CheckAssetByHashError,
|
||||
CheckAssetByHashErrors,
|
||||
CheckAssetByHashResponses,
|
||||
CheckHubUsernameData,
|
||||
CheckHubUsernameError,
|
||||
CheckHubUsernameErrors,
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClaimInviteCodeData,
|
||||
ClaimInviteCodeError,
|
||||
ClaimInviteCodeErrors,
|
||||
@@ -62,7 +68,19 @@ export type {
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
CreateHubAssetUploadUrlResponse,
|
||||
CreateHubAssetUploadUrlResponses,
|
||||
CreateHubProfileData,
|
||||
CreateHubProfileError,
|
||||
CreateHubProfileErrors,
|
||||
CreateHubProfileRequest,
|
||||
CreateHubProfileResponse,
|
||||
CreateHubProfileResponses,
|
||||
CreateInviteRequest,
|
||||
CreateSecretData,
|
||||
CreateSecretError,
|
||||
@@ -111,6 +129,11 @@ export type {
|
||||
DeleteAssetErrors,
|
||||
DeleteAssetResponse,
|
||||
DeleteAssetResponses,
|
||||
DeleteHubWorkflowData,
|
||||
DeleteHubWorkflowError,
|
||||
DeleteHubWorkflowErrors,
|
||||
DeleteHubWorkflowResponse,
|
||||
DeleteHubWorkflowResponses,
|
||||
DeleteSecretData,
|
||||
DeleteSecretError,
|
||||
DeleteSecretErrors,
|
||||
@@ -212,6 +235,16 @@ export type {
|
||||
GetGlobalSubgraphsErrors,
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHubProfileByUsernameData,
|
||||
GetHubProfileByUsernameError,
|
||||
GetHubProfileByUsernameErrors,
|
||||
GetHubProfileByUsernameResponse,
|
||||
GetHubProfileByUsernameResponses,
|
||||
GetHubWorkflowData,
|
||||
GetHubWorkflowError,
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetInviteCodeStatusData,
|
||||
GetInviteCodeStatusError,
|
||||
GetInviteCodeStatusErrors,
|
||||
@@ -250,11 +283,21 @@ export type {
|
||||
GetModelsInFolderErrors,
|
||||
GetModelsInFolderResponse,
|
||||
GetModelsInFolderResponses,
|
||||
GetMyHubProfileData,
|
||||
GetMyHubProfileError,
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPublishedWorkflowData,
|
||||
GetPublishedWorkflowError,
|
||||
GetPublishedWorkflowErrors,
|
||||
GetPublishedWorkflowResponse,
|
||||
GetPublishedWorkflowResponses,
|
||||
GetRawLogsData,
|
||||
GetRawLogsError,
|
||||
GetRawLogsErrors,
|
||||
@@ -305,11 +348,30 @@ export type {
|
||||
GetWorkspaceResponses,
|
||||
GlobalSubgraphData,
|
||||
GlobalSubgraphInfo,
|
||||
HubAssetUploadUrlRequest,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
HubProfileSummary,
|
||||
HubUsernameCheckResponse,
|
||||
HubWorkflowDetail,
|
||||
HubWorkflowListResponse,
|
||||
HubWorkflowSummary,
|
||||
HubWorkflowTemplateEntry,
|
||||
ImportPublishedAssetsData,
|
||||
ImportPublishedAssetsError,
|
||||
ImportPublishedAssetsErrors,
|
||||
ImportPublishedAssetsRequest,
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
JwksResponse,
|
||||
LabelRef,
|
||||
LeaveWorkspaceData,
|
||||
LeaveWorkspaceError,
|
||||
LeaveWorkspaceErrors,
|
||||
@@ -322,6 +384,21 @@ export type {
|
||||
ListAssetsResponse2,
|
||||
ListAssetsResponses,
|
||||
ListAssetsResponseWritable,
|
||||
ListHubLabelsData,
|
||||
ListHubLabelsError,
|
||||
ListHubLabelsErrors,
|
||||
ListHubLabelsResponse,
|
||||
ListHubLabelsResponses,
|
||||
ListHubWorkflowIndexData,
|
||||
ListHubWorkflowIndexError,
|
||||
ListHubWorkflowIndexErrors,
|
||||
ListHubWorkflowIndexResponse,
|
||||
ListHubWorkflowIndexResponses,
|
||||
ListHubWorkflowsData,
|
||||
ListHubWorkflowsError,
|
||||
ListHubWorkflowsErrors,
|
||||
ListHubWorkflowsResponse,
|
||||
ListHubWorkflowsResponses,
|
||||
ListInvitesResponse,
|
||||
ListMembersResponse,
|
||||
ListSecretsData,
|
||||
@@ -376,6 +453,11 @@ export type {
|
||||
PlanAvailability,
|
||||
PlanAvailabilityReason,
|
||||
PlanSeatSummary,
|
||||
PostAssetsFromWorkflowData,
|
||||
PostAssetsFromWorkflowError,
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PreviewPlanInfo,
|
||||
PreviewSubscribeData,
|
||||
PreviewSubscribeError,
|
||||
@@ -384,6 +466,13 @@ export type {
|
||||
PreviewSubscribeResponse,
|
||||
PreviewSubscribeResponse2,
|
||||
PreviewSubscribeResponses,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
PublishHubWorkflowErrors,
|
||||
PublishHubWorkflowRequest,
|
||||
PublishHubWorkflowResponse,
|
||||
PublishHubWorkflowResponses,
|
||||
RawLogsResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
@@ -455,6 +544,12 @@ export type {
|
||||
UpdateAssetTagsErrors,
|
||||
UpdateAssetTagsResponse,
|
||||
UpdateAssetTagsResponses,
|
||||
UpdateHubProfileData,
|
||||
UpdateHubProfileError,
|
||||
UpdateHubProfileErrors,
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateSecretData,
|
||||
UpdateSecretError,
|
||||
UpdateSecretErrors,
|
||||
@@ -486,6 +581,8 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
WorkflowApiAssetsRequest,
|
||||
WorkflowApiAssetsResponse,
|
||||
WorkflowForkedFrom,
|
||||
WorkflowListResponse,
|
||||
WorkflowResponse,
|
||||
|
||||
1000
packages/ingest-types/src/types.gen.ts
generated
1000
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
402
packages/ingest-types/src/zod.gen.ts
generated
402
packages/ingest-types/src/zod.gen.ts
generated
@@ -2,6 +2,207 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
export const zHubUsernameCheckResponse = z.object({
|
||||
username: z.string(),
|
||||
available: z.boolean(),
|
||||
suggestions: z.array(z.string()).optional(),
|
||||
validation_error: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubAssetUploadUrlResponse = z.object({
|
||||
upload_url: z.string(),
|
||||
public_url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
|
||||
export const zHubAssetUploadUrlRequest = z.object({
|
||||
filename: z.string(),
|
||||
content_type: z.string()
|
||||
})
|
||||
|
||||
export const zPublishHubWorkflowRequest = z.object({
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
workflow_filename: z.string(),
|
||||
asset_ids: z.array(z.string()),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
custom_nodes: z.array(z.string()).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_token_or_url: z.string().optional(),
|
||||
thumbnail_comparison_token_or_url: z.string().optional(),
|
||||
sample_image_tokens_or_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zAssetInfo = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
preview_url: z.string(),
|
||||
storage_url: z.string(),
|
||||
model: z.boolean(),
|
||||
public: z.boolean(),
|
||||
in_library: z.boolean()
|
||||
})
|
||||
|
||||
export const zHubProfileSummary = z.object({
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
avatar_url: z.string().optional()
|
||||
})
|
||||
|
||||
export const zLabelRef = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string()
|
||||
})
|
||||
|
||||
export const zHubWorkflowDetail = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(zLabelRef).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
thumbnail_comparison_url: z.string().optional(),
|
||||
models: z.array(zLabelRef).optional(),
|
||||
custom_nodes: z.array(zLabelRef).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
sample_image_urls: z.array(z.string()).optional(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
workflow_json: z.record(z.unknown()),
|
||||
assets: z.array(zAssetInfo),
|
||||
profile: zHubProfileSummary
|
||||
})
|
||||
|
||||
export const zHubWorkflowSummary = z.object({
|
||||
share_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(zLabelRef).optional(),
|
||||
models: z.array(zLabelRef).optional(),
|
||||
custom_nodes: z.array(zLabelRef).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
thumbnail_comparison_url: z.string().optional(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
profile: zHubProfileSummary,
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
sample_image_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowListResponse = z.object({
|
||||
workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubLabelInfo = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['tag', 'model', 'custom_node'])
|
||||
})
|
||||
|
||||
export const zHubLabelListResponse = z.object({
|
||||
labels: z.array(zHubLabelInfo)
|
||||
})
|
||||
|
||||
export const zHubWorkflowTemplateEntry = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
requiresCustomNodes: z.array(z.string()).optional(),
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().optional(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
logos: z.array(z.record(z.unknown())).optional(),
|
||||
date: z.string().optional(),
|
||||
io: z
|
||||
.object({
|
||||
inputs: z.array(z.record(z.unknown())).optional(),
|
||||
outputs: z.array(z.record(z.unknown())).optional()
|
||||
})
|
||||
.optional(),
|
||||
includeOnDistributions: z.array(z.string()).optional(),
|
||||
thumbnailUrl: z.string().optional(),
|
||||
thumbnailComparisonUrl: z.string().optional(),
|
||||
shareId: z.string().optional(),
|
||||
extendedDescription: z.string().optional(),
|
||||
metaDescription: z.string().optional(),
|
||||
howToUse: z.array(z.string()).optional(),
|
||||
suggestedUseCases: z.array(z.string()).optional(),
|
||||
faqItems: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
answer: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contentTemplate: z.string().optional()
|
||||
})
|
||||
|
||||
export const zUpdateHubProfileRequest = z.object({
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_token: z.string().nullish(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zCreateHubProfileRequest = z.object({
|
||||
workspace_id: z.string(),
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_token: z.string().optional(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zHubProfile = z.object({
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zImportPublishedAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
export const zPublishedWorkflowDetail = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
name: z.string(),
|
||||
listed: z.boolean(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
workflow_json: z.record(z.unknown()),
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zWorkflowApiAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zWorkflowApiAssetsRequest = z.object({
|
||||
workflow_api_json: z.record(z.unknown())
|
||||
})
|
||||
|
||||
export const zForkWorkflowRequest = z.object({
|
||||
source_version: z.number().int(),
|
||||
name: z.string().optional()
|
||||
@@ -992,7 +1193,9 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
include_public: z.boolean().optional().default(true)
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -1234,6 +1437,28 @@ export const zCheckAssetByHashData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPostAssetsFromWorkflowData = z.object({
|
||||
body: zWorkflowApiAssetsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zPostAssetsFromWorkflowResponse = zWorkflowApiAssetsResponse
|
||||
|
||||
export const zImportPublishedAssetsData = z.object({
|
||||
body: zImportPublishedAssetsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully imported assets
|
||||
*/
|
||||
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
|
||||
|
||||
export const zListSecretsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1633,6 +1858,13 @@ export const zCreateDeletionRequestData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Created - deletion request created or already exists
|
||||
*/
|
||||
export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -1928,3 +2160,171 @@ export const zForkWorkflowData = z.object({
|
||||
* Workflow forked successfully
|
||||
*/
|
||||
export const zForkWorkflowResponse = zWorkflowResponse
|
||||
|
||||
export const zCreateHubProfileData = z.object({
|
||||
body: zCreateHubProfileRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile created
|
||||
*/
|
||||
export const zCreateHubProfileResponse = zHubProfile
|
||||
|
||||
export const zGetMyHubProfileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile
|
||||
*/
|
||||
export const zGetMyHubProfileResponse = zHubProfile
|
||||
|
||||
export const zCheckHubUsernameData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
username: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Username availability result
|
||||
*/
|
||||
export const zCheckHubUsernameResponse = zHubUsernameCheckResponse
|
||||
|
||||
export const zGetHubProfileByUsernameData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile
|
||||
*/
|
||||
export const zGetHubProfileByUsernameResponse = zHubProfile
|
||||
|
||||
export const zUpdateHubProfileData = z.object({
|
||||
body: zUpdateHubProfileRequest,
|
||||
path: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile updated
|
||||
*/
|
||||
export const zUpdateHubProfileResponse = zHubProfile
|
||||
|
||||
export const zCreateHubAssetUploadUrlData = z.object({
|
||||
body: zHubAssetUploadUrlRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Presigned upload URL and token
|
||||
*/
|
||||
export const zCreateHubAssetUploadUrlResponse = zHubAssetUploadUrlResponse
|
||||
|
||||
export const zListHubLabelsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
type: z.enum(['tag', 'model', 'custom_node']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of labels
|
||||
*/
|
||||
export const zListHubLabelsResponse = zHubLabelListResponse
|
||||
|
||||
export const zListHubWorkflowsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().gte(1).lte(100).optional().default(20),
|
||||
search: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
detail: z.boolean().optional().default(false)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated list of hub workflows
|
||||
*/
|
||||
export const zListHubWorkflowsResponse = zHubWorkflowListResponse
|
||||
|
||||
export const zPublishHubWorkflowData = z.object({
|
||||
body: zPublishHubWorkflowRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow published to hub
|
||||
*/
|
||||
export const zPublishHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zListHubWorkflowIndexData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of hub workflow template entries
|
||||
*/
|
||||
export const zListHubWorkflowIndexResponse = z.array(zHubWorkflowTemplateEntry)
|
||||
|
||||
export const zDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully unpublished
|
||||
*/
|
||||
export const zDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zGetHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub workflow detail
|
||||
*/
|
||||
export const zGetHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zGetPublishedWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Published workflow details with asset statuses
|
||||
*/
|
||||
export const zGetPublishedWorkflowResponse = zPublishedWorkflowDetail
|
||||
|
||||
1665
pnpm-lock.yaml
generated
1665
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/vue': ^5.0.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^9.39.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
@@ -50,6 +51,7 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
@@ -58,6 +60,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^5.10.0
|
||||
axios: ^1.13.5
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
|
||||
@@ -22,6 +22,7 @@ interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -43,22 +44,46 @@ const HISTORY_DIR = 'temp/perf-history'
|
||||
|
||||
type MetricKey =
|
||||
| 'styleRecalcs'
|
||||
| 'styleRecalcDurationMs'
|
||||
| 'layouts'
|
||||
| 'layoutDurationMs'
|
||||
| 'taskDurationMs'
|
||||
| 'domNodes'
|
||||
| 'scriptDurationMs'
|
||||
| 'eventListeners'
|
||||
| 'totalBlockingTimeMs'
|
||||
| 'frameDurationMs'
|
||||
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
|
||||
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
|
||||
{ key: 'layouts', label: 'layouts', unit: '' },
|
||||
| 'heapUsedBytes'
|
||||
|
||||
interface MetricDef {
|
||||
key: MetricKey
|
||||
label: string
|
||||
unit: string
|
||||
/** Minimum absolute delta to consider meaningful (effect size gate) */
|
||||
minAbsDelta?: number
|
||||
}
|
||||
|
||||
const REPORTED_METRICS: MetricDef[] = [
|
||||
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
|
||||
{
|
||||
key: 'styleRecalcDurationMs',
|
||||
label: 'style recalc duration',
|
||||
unit: 'ms'
|
||||
},
|
||||
{ key: 'layouts', label: 'layout count', unit: '', minAbsDelta: 5 },
|
||||
{
|
||||
key: 'styleRecalcs',
|
||||
label: 'style recalc count',
|
||||
unit: '',
|
||||
minAbsDelta: 5
|
||||
},
|
||||
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
|
||||
{ key: 'domNodes', label: 'DOM nodes', unit: '' },
|
||||
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
|
||||
{ key: 'eventListeners', label: 'event listeners', unit: '' },
|
||||
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
|
||||
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' }
|
||||
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
|
||||
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
|
||||
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
|
||||
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
|
||||
]
|
||||
|
||||
function groupByName(
|
||||
@@ -134,7 +159,9 @@ function computeCV(stats: MetricStats): number {
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: string): string {
|
||||
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
|
||||
if (unit === 'ms') return `${value.toFixed(0)}ms`
|
||||
if (unit === 'bytes') return formatBytes(value)
|
||||
return `${value.toFixed(0)}`
|
||||
}
|
||||
|
||||
function formatDelta(pct: number | null): string {
|
||||
@@ -159,6 +186,21 @@ function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null {
|
||||
return values.reduce((sum, v) => sum + v, 0) / values.length
|
||||
}
|
||||
|
||||
function medianMetric(
|
||||
samples: PerfMeasurement[],
|
||||
key: MetricKey
|
||||
): number | null {
|
||||
const values = samples
|
||||
.map((s) => getMetricValue(s, key))
|
||||
.filter((v): v is number => v !== null)
|
||||
.sort((a, b) => a - b)
|
||||
if (values.length === 0) return null
|
||||
const mid = Math.floor(values.length / 2)
|
||||
return values.length % 2 === 0
|
||||
? (values[mid - 1] + values[mid]) / 2
|
||||
: values[mid]
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (Math.abs(bytes) < 1024) return `${bytes} B`
|
||||
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
@@ -173,7 +215,7 @@ function renderFullReport(
|
||||
const lines: string[] = []
|
||||
const baselineGroups = groupByName(baseline.measurements)
|
||||
const tableHeader = [
|
||||
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
|
||||
'| Metric | Baseline | PR (median) | Δ | Sig |',
|
||||
'|--------|----------|----------|---|-----|'
|
||||
]
|
||||
|
||||
@@ -183,36 +225,38 @@ function renderFullReport(
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
const baseSamples = baselineGroups.get(testName)
|
||||
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
for (const { key, label, unit, minAbsDelta } of REPORTED_METRICS) {
|
||||
// Use median for PR values — robust to outlier runs in CI
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
const histStats = getHistoricalStats(historical, testName, key)
|
||||
const cv = computeCV(histStats)
|
||||
|
||||
if (!baseSamples?.length) {
|
||||
allRows.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const baseVal = meanMetric(baseSamples, key)
|
||||
const baseVal = medianMetric(baseSamples, key)
|
||||
if (baseVal === null) {
|
||||
allRows.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const absDelta = prVal - baseVal
|
||||
const deltaPct =
|
||||
baseVal === 0
|
||||
? prMean === 0
|
||||
? prVal === 0
|
||||
? 0
|
||||
: null
|
||||
: ((prMean - baseVal) / baseVal) * 100
|
||||
const z = zScore(prMean, histStats)
|
||||
const sig = classifyChange(z, cv)
|
||||
: ((prVal - baseVal) / baseVal) * 100
|
||||
const z = zScore(prVal, histStats)
|
||||
const sig = classifyChange(z, cv, absDelta, minAbsDelta)
|
||||
|
||||
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
|
||||
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
|
||||
allRows.push(row)
|
||||
if (isNoteworthy(sig)) {
|
||||
flaggedRows.push(row)
|
||||
@@ -299,7 +343,7 @@ function renderColdStartReport(
|
||||
const lines: string[] = []
|
||||
const baselineGroups = groupByName(baseline.measurements)
|
||||
lines.push(
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
|
||||
'',
|
||||
'| Metric | Baseline | PR | Δ |',
|
||||
'|--------|----------|-----|---|'
|
||||
@@ -309,31 +353,31 @@ function renderColdStartReport(
|
||||
const baseSamples = baselineGroups.get(testName)
|
||||
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
|
||||
if (!baseSamples?.length) {
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const baseVal = meanMetric(baseSamples, key)
|
||||
const baseVal = medianMetric(baseSamples, key)
|
||||
if (baseVal === null) {
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const deltaPct =
|
||||
baseVal === 0
|
||||
? prMean === 0
|
||||
? prVal === 0
|
||||
? 0
|
||||
: null
|
||||
: ((prMean - baseVal) / baseVal) * 100
|
||||
: ((prVal - baseVal) / baseVal) * 100
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
|
||||
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -352,14 +396,10 @@ function renderNoBaselineReport(
|
||||
)
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
|
||||
}
|
||||
const heapMean =
|
||||
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
|
||||
prSamples.length
|
||||
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -99,6 +99,21 @@ describe('classifyChange', () => {
|
||||
expect(classifyChange(2, 10)).toBe('neutral')
|
||||
expect(classifyChange(-2, 10)).toBe('neutral')
|
||||
})
|
||||
|
||||
it('returns neutral when absDelta below minAbsDelta despite high z', () => {
|
||||
// z=7.2 but only 1 unit change with minAbsDelta=5
|
||||
expect(classifyChange(7.2, 10, 1, 5)).toBe('neutral')
|
||||
expect(classifyChange(-7.2, 10, -1, 5)).toBe('neutral')
|
||||
})
|
||||
|
||||
it('returns regression when absDelta meets minAbsDelta', () => {
|
||||
expect(classifyChange(3, 10, 10, 5)).toBe('regression')
|
||||
})
|
||||
|
||||
it('ignores effect size gate when minAbsDelta not provided', () => {
|
||||
expect(classifyChange(3, 10)).toBe('regression')
|
||||
expect(classifyChange(3, 10, 1)).toBe('regression')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSignificance', () => {
|
||||
|
||||
@@ -31,12 +31,28 @@ export function zScore(value: number, stats: MetricStats): number | null {
|
||||
|
||||
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
|
||||
|
||||
/**
|
||||
* Classify a metric change as regression/improvement/neutral/noisy.
|
||||
*
|
||||
* Uses both statistical significance (z-score) and practical significance
|
||||
* (effect size gate via minAbsDelta) to reduce false positives from
|
||||
* integer-quantized metrics with near-zero variance.
|
||||
*/
|
||||
export function classifyChange(
|
||||
z: number | null,
|
||||
historicalCV: number
|
||||
historicalCV: number,
|
||||
absDelta?: number,
|
||||
minAbsDelta?: number
|
||||
): Significance {
|
||||
if (historicalCV > 50) return 'noisy'
|
||||
if (z === null) return 'neutral'
|
||||
|
||||
// Effect size gate: require minimum absolute change for count metrics
|
||||
// to avoid flagging e.g. 11→12 style recalcs as z=7.2 regression.
|
||||
if (minAbsDelta !== undefined && absDelta !== undefined) {
|
||||
if (Math.abs(absDelta) < minAbsDelta) return 'neutral'
|
||||
}
|
||||
|
||||
if (z > 2) return 'regression'
|
||||
if (z < -2) return 'improvement'
|
||||
return 'neutral'
|
||||
|
||||
@@ -3167,6 +3167,7 @@
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"title": "Publish to ComfyHub",
|
||||
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
|
||||
"stepDescribe": "Describe your workflow",
|
||||
"stepExamples": "Add output examples",
|
||||
"stepFinish": "Finish publishing",
|
||||
@@ -3174,12 +3175,6 @@
|
||||
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
|
||||
"workflowDescription": "Workflow description",
|
||||
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
|
||||
"workflowType": "Workflow type",
|
||||
"workflowTypePlaceholder": "Select the type",
|
||||
"workflowTypeImageGeneration": "Image generation",
|
||||
"workflowTypeVideoGeneration": "Video generation",
|
||||
"workflowTypeUpscaling": "Upscaling",
|
||||
"workflowTypeEditing": "Editing",
|
||||
"tags": "Tags",
|
||||
"tagsDescription": "Select tags so people can find your workflow faster",
|
||||
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
|
||||
@@ -3206,11 +3201,17 @@
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
"removeExampleImage": "Remove example image",
|
||||
"exampleImage": "Example image {index}",
|
||||
"exampleImagePosition": "Example image {index} of {total}",
|
||||
"videoPreview": "Video thumbnail preview",
|
||||
"maxExamples": "You can select up to {max} examples",
|
||||
"shareAs": "Share as",
|
||||
"additionalInfo": "Additional information",
|
||||
"createProfileToPublish": "Create a profile to publish to ComfyHub",
|
||||
"createProfileCta": "Create a profile"
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
|
||||
},
|
||||
"comfyHubProfile": {
|
||||
"checkingAccess": "Checking your publishing access...",
|
||||
@@ -3229,6 +3230,7 @@
|
||||
"namePlaceholder": "Enter your name here",
|
||||
"usernameLabel": "Your username (required)",
|
||||
"usernamePlaceholder": "@",
|
||||
"usernameError": "3–42 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
|
||||
"descriptionLabel": "Your description",
|
||||
"descriptionPlaceholder": "Tell the community about yourself...",
|
||||
"createProfile": "Create profile",
|
||||
|
||||
@@ -24,7 +24,13 @@ vi.mock('@/platform/telemetry/topupTracker', () => ({
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
|
||||
mockActiveWorkflow: null as null | {
|
||||
filename: string
|
||||
fullFilename: string
|
||||
},
|
||||
mockKnownTemplateNames: new Set<string>(),
|
||||
mockTemplateByName: null as null | { sourceModule?: string }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
@@ -35,7 +41,9 @@ vi.mock('@/stores/nodeDefStore', () => ({
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
get activeWorkflow() {
|
||||
return hoisted.mockActiveWorkflow
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -43,7 +51,11 @@ vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
knownTemplateNames: new Set()
|
||||
get knownTemplateNames() {
|
||||
return hoisted.mockKnownTemplateNames
|
||||
},
|
||||
getTemplateByName: (_name: string) => hoisted.mockTemplateByName,
|
||||
getEnglishMetadata: () => null
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -85,6 +97,9 @@ describe('getExecutionContext', () => {
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
hoisted.mockActiveWorkflow = null
|
||||
hoisted.mockKnownTemplateNames = new Set()
|
||||
hoisted.mockTemplateByName = null
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
@@ -175,4 +190,50 @@ describe('getExecutionContext', () => {
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
|
||||
describe('template detection', () => {
|
||||
it('detects a regular template by name', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'flux-dev',
|
||||
fullFilename: 'flux-dev.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('flux-dev')
|
||||
})
|
||||
|
||||
it('detects an app mode template whose name ends with .app', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set([
|
||||
'templates-qwen_multiangle.app'
|
||||
])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
// getFilenameDetails strips ".app.json" as a compound extension, yielding
|
||||
// filename = "templates-qwen_multiangle" — the previous code would fail here.
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'templates-qwen_multiangle',
|
||||
fullFilename: 'templates-qwen_multiangle.app.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('templates-qwen_multiangle.app')
|
||||
})
|
||||
|
||||
it('does not flag a non-template workflow as a template', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'my-custom-workflow',
|
||||
fullFilename: 'my-custom-workflow.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,20 +80,21 @@ export function getExecutionContext(): ExecutionContext {
|
||||
)
|
||||
|
||||
if (activeWorkflow?.filename) {
|
||||
const isTemplate = templatesStore.knownTemplateNames.has(
|
||||
activeWorkflow.filename
|
||||
)
|
||||
// Use fullFilename minus .json to reconstruct the template name, which
|
||||
// preserves compound suffixes like ".app" (e.g. "foo.app.json" → "foo.app").
|
||||
// Using just `filename` strips ".app.json" entirely (e.g. "foo"), which
|
||||
// won't match knownTemplateNames entries like "foo.app".
|
||||
const templateName = activeWorkflow.fullFilename.replace(/\.json$/i, '')
|
||||
const isTemplate = templatesStore.knownTemplateNames.has(templateName)
|
||||
|
||||
if (isTemplate) {
|
||||
const template = templatesStore.getTemplateByName(activeWorkflow.filename)
|
||||
const template = templatesStore.getTemplateByName(templateName)
|
||||
|
||||
const englishMetadata = templatesStore.getEnglishMetadata(
|
||||
activeWorkflow.filename
|
||||
)
|
||||
const englishMetadata = templatesStore.getEnglishMetadata(templateName)
|
||||
|
||||
return {
|
||||
is_template: true,
|
||||
workflow_name: activeWorkflow.filename,
|
||||
workflow_name: templateName,
|
||||
template_source: template?.sourceModule,
|
||||
template_category: englishMetadata?.category ?? template?.category,
|
||||
template_tags: englishMetadata?.tags ?? template?.tags,
|
||||
|
||||
@@ -133,9 +133,45 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-publish"
|
||||
data-testid="publish-tab-panel"
|
||||
class="min-h-0"
|
||||
class="flex min-h-0 flex-col gap-4"
|
||||
>
|
||||
<template v-if="dialogState === 'loading'">
|
||||
<Skeleton class="h-3 w-4/5" />
|
||||
<Skeleton class="h-3 w-3/5" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="dialogState === 'unsaved'">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="publishNameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<ComfyHubPublishIntroPanel
|
||||
v-else
|
||||
data-testid="publish-intro"
|
||||
:on-create-profile="handleOpenPublishDialog"
|
||||
:on-close="onClose"
|
||||
@@ -215,10 +251,15 @@ const dialogMode = ref<DialogMode>('shareLink')
|
||||
const acknowledged = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
const publishNameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
function focusNameInput() {
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
function focusActiveNameInput() {
|
||||
const input =
|
||||
dialogMode.value === 'publishToHub'
|
||||
? publishNameInputRef.value
|
||||
: nameInputRef.value
|
||||
input?.focus()
|
||||
input?.select()
|
||||
}
|
||||
|
||||
const isTemporary = computed(
|
||||
@@ -228,7 +269,7 @@ const isTemporary = computed(
|
||||
watch(dialogState, async (state) => {
|
||||
if (state === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusNameInput()
|
||||
focusActiveNameInput()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -255,10 +296,14 @@ function tabButtonClass(mode: DialogMode) {
|
||||
)
|
||||
}
|
||||
|
||||
function handleDialogModeChange(nextMode: DialogMode) {
|
||||
async function handleDialogModeChange(nextMode: DialogMode) {
|
||||
if (nextMode === dialogMode.value) return
|
||||
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
|
||||
dialogMode.value = nextMode
|
||||
if (dialogState.value === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusActiveNameInput()
|
||||
}
|
||||
}
|
||||
|
||||
watch(showPublishToHubTab, (isVisible) => {
|
||||
|
||||
@@ -75,8 +75,23 @@
|
||||
>
|
||||
@
|
||||
</span>
|
||||
<Input id="profile-username" v-model="username" class="pl-7" />
|
||||
<Input
|
||||
id="profile-username"
|
||||
v-model="username"
|
||||
class="pl-7"
|
||||
:aria-invalid="showUsernameError ? 'true' : 'false'"
|
||||
:aria-describedby="
|
||||
showUsernameError ? 'profile-username-error' : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="showUsernameError"
|
||||
id="profile-username-error"
|
||||
class="text-xs text-destructive-background"
|
||||
>
|
||||
{{ $t('comfyHubProfile.usernameError') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -105,7 +120,7 @@
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!username.trim() || isCreating"
|
||||
:disabled="!isUsernameValid || isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{
|
||||
@@ -156,6 +171,16 @@ const profilePictureFile = ref<File | null>(null)
|
||||
const profilePreviewUrl = useObjectUrl(profilePictureFile)
|
||||
const isCreating = ref(false)
|
||||
|
||||
const VALID_USERNAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$/
|
||||
|
||||
const isUsernameValid = computed(() =>
|
||||
VALID_USERNAME_PATTERN.test(username.value)
|
||||
)
|
||||
|
||||
const showUsernameError = computed(
|
||||
() => username.value.length > 0 && !isUsernameValid.value
|
||||
)
|
||||
|
||||
const profileInitial = computed(() => {
|
||||
const source = name.value.trim() || username.value.trim()
|
||||
return source ? source[0].toUpperCase() : 'C'
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
|
||||
function mountStep(
|
||||
props: Partial<InstanceType<typeof ComfyHubDescribeStep>['$props']> = {}
|
||||
) {
|
||||
return mount(ComfyHubDescribeStep, {
|
||||
props: {
|
||||
name: 'Workflow Name',
|
||||
description: 'Workflow description',
|
||||
tags: [],
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
Input: {
|
||||
template:
|
||||
'<input data-testid="name-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue']
|
||||
},
|
||||
Textarea: {
|
||||
template:
|
||||
'<textarea data-testid="description-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue']
|
||||
},
|
||||
TagsInput: {
|
||||
template:
|
||||
'<div data-testid="tags-input" :data-disabled="disabled ? \'true\' : \'false\'"><slot :is-empty="!modelValue || modelValue.length === 0" /></div>',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean
|
||||
}
|
||||
},
|
||||
TagsInputItem: {
|
||||
template:
|
||||
'<button data-testid="tag-item" :data-value="value" type="button"><slot /></button>',
|
||||
props: ['value']
|
||||
},
|
||||
TagsInputItemText: {
|
||||
template: '<span data-testid="tag-item-text" />'
|
||||
},
|
||||
TagsInputItemDelete: {
|
||||
template: '<button data-testid="tag-item-delete" type="button" />'
|
||||
},
|
||||
TagsInputInput: {
|
||||
template: '<input data-testid="tags-input-input" />'
|
||||
},
|
||||
Button: {
|
||||
template:
|
||||
'<button data-testid="toggle-suggestions" type="button"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubDescribeStep', () => {
|
||||
it('emits name and description updates', async () => {
|
||||
const wrapper = mountStep()
|
||||
|
||||
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
|
||||
await wrapper
|
||||
.find('[data-testid="description-input"]')
|
||||
.setValue('New description')
|
||||
|
||||
expect(wrapper.emitted('update:name')).toEqual([['New workflow']])
|
||||
expect(wrapper.emitted('update:description')).toEqual([['New description']])
|
||||
})
|
||||
|
||||
it('adds a suggested tag when clicked', async () => {
|
||||
const wrapper = mountStep()
|
||||
const suggestionButtons = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
|
||||
expect(suggestionButtons.length).toBeGreaterThan(0)
|
||||
|
||||
const firstSuggestion = suggestionButtons[0].attributes('data-value')
|
||||
await suggestionButtons[0].trigger('click')
|
||||
|
||||
const tagUpdates = wrapper.emitted('update:tags')
|
||||
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
|
||||
})
|
||||
|
||||
it('hides already-selected tags from suggestions', () => {
|
||||
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
|
||||
const wrapper = mountStep({ tags: [selectedTag] })
|
||||
const suggestionValues = wrapper
|
||||
.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
.map((button) => button.attributes('data-value'))
|
||||
|
||||
expect(suggestionValues).not.toContain(selectedTag)
|
||||
})
|
||||
|
||||
it('toggles between default and full suggestion lists', async () => {
|
||||
const wrapper = mountStep()
|
||||
|
||||
const defaultSuggestions = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
expect(defaultSuggestions).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain('comfyHubPublish.showMoreTags')
|
||||
|
||||
await wrapper.find('[data-testid="toggle-suggestions"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const allSuggestions = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
expect(allSuggestions).toHaveLength(COMFY_HUB_TAG_OPTIONS.length)
|
||||
expect(wrapper.text()).toContain('comfyHubPublish.showLessTags')
|
||||
})
|
||||
})
|
||||
@@ -25,35 +25,8 @@
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.workflowType') }}
|
||||
</span>
|
||||
<Select
|
||||
:model-value="workflowType"
|
||||
@update:model-value="
|
||||
emit('update:workflowType', $event as ComfyHubWorkflowType)
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in workflowTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.tagsDescription') }}
|
||||
</legend>
|
||||
</span>
|
||||
<TagsInput
|
||||
v-slot="{ isEmpty }"
|
||||
always-editing
|
||||
@@ -67,54 +40,48 @@
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty />
|
||||
</TagsInput>
|
||||
|
||||
<TagsInput
|
||||
disabled
|
||||
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
|
||||
</label>
|
||||
<TagsInput
|
||||
disabled
|
||||
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="flex basis-full flex-wrap gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="flex basis-full flex-wrap gap-2"
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="hover:bg-unset px-0 text-xs"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</fieldset>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="hover:bg-unset px-0 text-xs"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.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'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||
@@ -122,46 +89,21 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
|
||||
const { tags, workflowType } = defineProps<{
|
||||
const { tags } = defineProps<{
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:description': [value: string]
|
||||
'update:workflowType': [value: ComfyHubWorkflowType | '']
|
||||
'update:tags': [value: string[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workflowTypeOptions = computed(() => [
|
||||
{
|
||||
value: 'imageGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeImageGeneration')
|
||||
},
|
||||
{
|
||||
value: 'videoGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeVideoGeneration')
|
||||
},
|
||||
{
|
||||
value: 'upscaling',
|
||||
label: t('comfyHubPublish.workflowTypeUpscaling')
|
||||
},
|
||||
{
|
||||
value: 'editing',
|
||||
label: t('comfyHubPublish.workflowTypeEditing')
|
||||
}
|
||||
])
|
||||
|
||||
const INITIAL_TAG_SUGGESTION_COUNT = 10
|
||||
|
||||
const showAllSuggestions = ref(false)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
|
||||
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
|
||||
draggable: vi.fn(() => vi.fn()),
|
||||
dropTargetForElements: vi.fn(() => vi.fn()),
|
||||
monitorForElements: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
function createImages(count: number): ExampleImage[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `img-${i}`,
|
||||
url: `blob:http://localhost/img-${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
function mountStep(images: ExampleImage[]) {
|
||||
return mount(ComfyHubExamplesStep, {
|
||||
props: { exampleImages: images },
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubExamplesStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders all example images', () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
expect(wrapper.findAll('[role="listitem"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('emits reordered array when moving image left via keyboard', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[1].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
const reordered = emitted![0][0] as ExampleImage[]
|
||||
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-0', 'img-2'])
|
||||
})
|
||||
|
||||
it('emits reordered array when moving image right via keyboard', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[1].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
const reordered = emitted![0][0] as ExampleImage[]
|
||||
expect(reordered.map((img) => img.id)).toEqual(['img-0', 'img-2', 'img-1'])
|
||||
})
|
||||
|
||||
it('does not emit when moving first image left (boundary)', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[0].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
|
||||
|
||||
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not emit when moving last image right (boundary)', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[2].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
|
||||
|
||||
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits filtered array when removing an image', async () => {
|
||||
const wrapper = mountStep(createImages(2))
|
||||
|
||||
const removeBtn = wrapper.find(
|
||||
'button[aria-label="comfyHubPublish.removeExampleImage"]'
|
||||
)
|
||||
expect(removeBtn.exists()).toBe(true)
|
||||
await removeBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0][0]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,25 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<p class="text-sm">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<p class="text-sm select-none">
|
||||
{{
|
||||
$t('comfyHubPublish.examplesDescription', {
|
||||
selected: selectedExampleIds.length,
|
||||
total: MAX_EXAMPLES
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
|
||||
<!-- Upload tile -->
|
||||
<div
|
||||
role="list"
|
||||
class="group/grid grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, 8rem)"
|
||||
>
|
||||
<!-- Upload tile (hidden when max images reached) -->
|
||||
<label
|
||||
v-if="showUploadTile"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
|
||||
class="focus-visible:outline-ring flex aspect-square h-25 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
class="focus-visible:outline-ring flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
@dragenter.stop
|
||||
@dragleave.stop
|
||||
@dragover.prevent.stop
|
||||
@@ -40,83 +44,100 @@
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<!-- Example images -->
|
||||
<Button
|
||||
<!-- Example images (drag to reorder) -->
|
||||
<ReorderableExampleImage
|
||||
v-for="(image, index) in exampleImages"
|
||||
:key="image.id"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
|
||||
isSelected(image.id) ? 'ring-ring ring-2' : 'ring-0'
|
||||
)
|
||||
"
|
||||
@click="toggleSelection(image.id)"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-if="isSelected(image.id)"
|
||||
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
|
||||
>
|
||||
{{ selectionIndex(image.id) }}
|
||||
</div>
|
||||
</Button>
|
||||
:image="image"
|
||||
:index="index"
|
||||
:total="exampleImages.length"
|
||||
:instance-id="instanceId"
|
||||
@remove="removeImage"
|
||||
@move="moveImage"
|
||||
@insert-files="insertImagesAt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import {
|
||||
isFileTooLarge,
|
||||
MAX_IMAGE_SIZE_MB
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import ReorderableExampleImage from './ReorderableExampleImage.vue'
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const MAX_EXAMPLES = 8
|
||||
|
||||
const { exampleImages, selectedExampleIds } = defineProps<{
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
}>()
|
||||
const exampleImages = defineModel<ExampleImage[]>('exampleImages', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:exampleImages': [value: ExampleImage[]]
|
||||
'update:selectedExampleIds': [value: string[]]
|
||||
}>()
|
||||
const showUploadTile = computed(() => exampleImages.value.length < MAX_EXAMPLES)
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedExampleIds.includes(id)
|
||||
const instanceId = Symbol('example-images')
|
||||
|
||||
let cleanupMonitor = () => {}
|
||||
|
||||
onMounted(() => {
|
||||
cleanupMonitor = monitorForElements({
|
||||
canMonitor: ({ source }) => source.data.instanceId === instanceId,
|
||||
onDrop: ({ source, location }) => {
|
||||
const destination = location.current.dropTargets[0]
|
||||
if (!destination) return
|
||||
|
||||
const fromId = source.data.imageId
|
||||
const toId = destination.data.imageId
|
||||
if (typeof fromId !== 'string' || typeof toId !== 'string') return
|
||||
|
||||
reorderImages(fromId, toId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMonitor()
|
||||
})
|
||||
|
||||
function moveByIndex(fromIndex: number, toIndex: number) {
|
||||
if (fromIndex < 0 || toIndex < 0) return
|
||||
if (toIndex >= exampleImages.value.length || fromIndex === toIndex) return
|
||||
|
||||
const updated = [...exampleImages.value]
|
||||
const [moved] = updated.splice(fromIndex, 1)
|
||||
updated.splice(toIndex, 0, moved)
|
||||
exampleImages.value = updated
|
||||
}
|
||||
|
||||
function selectionIndex(id: string): number {
|
||||
return selectedExampleIds.indexOf(id) + 1
|
||||
function reorderImages(fromId: string, toId: string) {
|
||||
moveByIndex(
|
||||
exampleImages.value.findIndex((img) => img.id === fromId),
|
||||
exampleImages.value.findIndex((img) => img.id === toId)
|
||||
)
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (isSelected(id)) {
|
||||
emit(
|
||||
'update:selectedExampleIds',
|
||||
selectedExampleIds.filter((sid) => sid !== id)
|
||||
)
|
||||
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
|
||||
emit('update:selectedExampleIds', [...selectedExampleIds, id])
|
||||
function moveImage(id: string, direction: number) {
|
||||
const currentIndex = exampleImages.value.findIndex((img) => img.id === id)
|
||||
moveByIndex(currentIndex, currentIndex + direction)
|
||||
}
|
||||
|
||||
function removeImage(id: string) {
|
||||
const image = exampleImages.value.find((img) => img.id === id)
|
||||
if (image?.file) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
exampleImages.value = exampleImages.value.filter((img) => img.id !== id)
|
||||
}
|
||||
|
||||
function addImages(files: FileList) {
|
||||
const newImages: ExampleImage[] = Array.from(files)
|
||||
function createExampleImages(files: FileList): ExampleImage[] {
|
||||
return Array.from(files)
|
||||
.filter((f) => f.type.startsWith('image/'))
|
||||
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
|
||||
.map((file) => ({
|
||||
@@ -124,10 +145,51 @@ function addImages(files: FileList) {
|
||||
url: URL.createObjectURL(file),
|
||||
file
|
||||
}))
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
emit('update:exampleImages', [...exampleImages, ...newImages])
|
||||
function addImages(files: FileList) {
|
||||
const remaining = MAX_EXAMPLES - exampleImages.value.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
const created = createExampleImages(files)
|
||||
const newImages = created.slice(0, remaining)
|
||||
for (const img of created.slice(remaining)) {
|
||||
URL.revokeObjectURL(img.url)
|
||||
}
|
||||
if (newImages.length > 0) {
|
||||
exampleImages.value = [...newImages, ...exampleImages.value]
|
||||
}
|
||||
}
|
||||
|
||||
function insertImagesAt(index: number, files: FileList) {
|
||||
const created = createExampleImages(files)
|
||||
if (created.length === 0) return
|
||||
|
||||
const updated = [...exampleImages.value]
|
||||
const safeIndex = Math.min(Math.max(index, 0), updated.length)
|
||||
const remaining = MAX_EXAMPLES - exampleImages.value.length
|
||||
const maxInsert =
|
||||
remaining <= 0 ? Math.max(updated.length - safeIndex, 0) : remaining
|
||||
const newImages = created.slice(0, maxInsert)
|
||||
for (const img of created.slice(maxInsert)) {
|
||||
URL.revokeObjectURL(img.url)
|
||||
}
|
||||
|
||||
if (newImages.length === 0) return
|
||||
if (remaining <= 0) {
|
||||
const replacedImages = updated.splice(
|
||||
safeIndex,
|
||||
newImages.length,
|
||||
...newImages
|
||||
)
|
||||
for (const img of replacedImages) {
|
||||
if (img.file) URL.revokeObjectURL(img.url)
|
||||
}
|
||||
} else {
|
||||
updated.splice(safeIndex, 0, ...newImages)
|
||||
}
|
||||
|
||||
exampleImages.value = updated
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4">
|
||||
<section class="flex flex-col gap-4">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.shareAs') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-2xl bg-secondary-background px-6 py-4"
|
||||
>
|
||||
<div
|
||||
class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-linear-to-b from-green-600/50 to-green-900"
|
||||
>
|
||||
<img
|
||||
v-if="profile.profilePictureUrl"
|
||||
:src="profile.profilePictureUrl"
|
||||
:alt="profile.username"
|
||||
class="size-full rounded-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-base text-white">
|
||||
{{ (profile.name ?? profile.username).charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ profile.name ?? profile.username }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
@{{ profile.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="isLoadingAssets || hasPrivateAssets"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.additionalInfo') }}
|
||||
</span>
|
||||
|
||||
<p
|
||||
v-if="isLoadingAssets"
|
||||
class="m-0 text-sm text-muted-foreground italic"
|
||||
>
|
||||
{{ $t('shareWorkflow.checkingAssets') }}
|
||||
</p>
|
||||
<ShareAssetWarningBox
|
||||
v-else
|
||||
v-model:acknowledged="acknowledged"
|
||||
:items="privateAssets"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
|
||||
const { profile } = defineProps<{
|
||||
profile: ComfyHubProfile
|
||||
}>()
|
||||
|
||||
const acknowledged = defineModel<boolean>('acknowledged', { default: false })
|
||||
const ready = defineModel<boolean>('ready', { default: false })
|
||||
|
||||
const shareService = useWorkflowShareService()
|
||||
|
||||
const {
|
||||
state: privateAssets,
|
||||
isLoading: isLoadingAssets,
|
||||
error: privateAssetsError
|
||||
} = useAsyncState(() => shareService.getShareableAssets(), [])
|
||||
|
||||
const hasPrivateAssets = computed(() => privateAssets.value.length > 0)
|
||||
const isReady = computed(
|
||||
() =>
|
||||
!isLoadingAssets.value &&
|
||||
!privateAssetsError.value &&
|
||||
(!hasPrivateAssets.value || acknowledged.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
(val) => {
|
||||
ready.value = val
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -2,6 +2,18 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
|
||||
|
||||
const mockFetchProfile = vi.hoisted(() => vi.fn())
|
||||
@@ -10,6 +22,11 @@ const mockGoNext = vi.hoisted(() => vi.fn())
|
||||
const mockGoBack = vi.hoisted(() => vi.fn())
|
||||
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockApplyPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
|
||||
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
@@ -28,14 +45,16 @@ vi.mock(
|
||||
formData: ref({
|
||||
name: '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
@@ -43,17 +62,64 @@ vi.mock(
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
}),
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
|
||||
() => ({
|
||||
useComfyHubPublishSubmission: () => ({
|
||||
submitToComfyHub: mockSubmitToComfyHub
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getPublishStatus: mockGetPublishStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
renameWorkflow: vi.fn(),
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ComfyHubPublishDialog', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchProfile.mockResolvedValue(null)
|
||||
mockSubmitToComfyHub.mockResolvedValue(undefined)
|
||||
mockGetCachedPrefill.mockReturnValue(null)
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
})
|
||||
})
|
||||
|
||||
function createWrapper() {
|
||||
@@ -78,14 +144,16 @@ describe('ComfyHubPublishDialog', () => {
|
||||
},
|
||||
ComfyHubPublishWizardContent: {
|
||||
template:
|
||||
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
|
||||
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
|
||||
props: [
|
||||
'currentStep',
|
||||
'formData',
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'isPublishing',
|
||||
'onGoNext',
|
||||
'onGoBack',
|
||||
'onPublish',
|
||||
'onRequireProfile',
|
||||
'onGateComplete',
|
||||
'onGateClose'
|
||||
@@ -136,4 +204,72 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dialog after successful publish', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="publish"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applies prefill when workflow is already published with metadata', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc123',
|
||||
shareUrl: 'http://localhost/?share=abc123',
|
||||
publishedAt: new Date(),
|
||||
prefill: {
|
||||
description: 'Existing description',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'video',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
}
|
||||
})
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).toHaveBeenCalledWith({
|
||||
description: 'Existing description',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'video',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply prefill when workflow is not published', async () => {
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not apply prefill when status has no prefill data', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc123',
|
||||
shareUrl: 'http://localhost/?share=abc123',
|
||||
publishedAt: new Date(),
|
||||
prefill: null
|
||||
})
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently ignores prefill fetch errors', async () => {
|
||||
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,44 +12,106 @@
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<ComfyHubPublishNav :current-step @step-click="goToStep" />
|
||||
<ComfyHubPublishNav
|
||||
v-if="!needsSave"
|
||||
:current-step
|
||||
@step-click="goToStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #header />
|
||||
<template #content>
|
||||
<div v-if="needsSave" class="flex flex-col gap-4 p-6">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="nameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<ComfyHubPublishWizardContent
|
||||
v-else
|
||||
:current-step
|
||||
:form-data
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publishing
|
||||
:on-update-form-data="updateFormData"
|
||||
:on-go-next="goNext"
|
||||
:on-go-back="goBack"
|
||||
:on-require-profile="handleRequireProfile"
|
||||
:on-gate-complete="handlePublishGateComplete"
|
||||
:on-gate-close="handlePublishGateClose"
|
||||
:on-publish="onClose"
|
||||
:on-publish="handlePublish"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, provide } from 'vue'
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
|
||||
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
|
||||
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
|
||||
import {
|
||||
cachePublishPrefill,
|
||||
getCachedPrefill,
|
||||
useComfyHubPublishWizard
|
||||
} from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { fetchProfile } = useComfyHubProfileGate()
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
currentStep,
|
||||
formData,
|
||||
@@ -59,8 +121,72 @@ const {
|
||||
goNext,
|
||||
goBack,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
closeProfileCreationStep,
|
||||
applyPrefill
|
||||
} = useComfyHubPublishWizard()
|
||||
const isPublishing = ref(false)
|
||||
const needsSave = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
const isTemporary = computed(
|
||||
() => workflowStore.activeWorkflow?.isTemporary ?? false
|
||||
)
|
||||
|
||||
function checkNeedsSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
needsSave.value = !workflow || workflow.isTemporary || workflow.isModified
|
||||
if (workflow) {
|
||||
workflowName.value = workflow.filename.replace(/\.json$/i, '')
|
||||
}
|
||||
}
|
||||
|
||||
watch(needsSave, async (needs) => {
|
||||
if (needs && isTemporary.value) {
|
||||
await nextTick()
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
}
|
||||
})
|
||||
|
||||
function buildWorkflowPath(directory: string, filename: string): string {
|
||||
const normalizedDirectory = directory.replace(/\/+$/, '')
|
||||
const normalizedFilename = appendJsonExt(filename.replace(/\.json$/i, ''))
|
||||
return normalizedDirectory
|
||||
? `${normalizedDirectory}/${normalizedFilename}`
|
||||
: normalizedFilename
|
||||
}
|
||||
|
||||
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
|
||||
async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
const name = workflowName.value.trim()
|
||||
if (!name) return
|
||||
const newPath = buildWorkflowPath(workflow.directory, name)
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
|
||||
checkNeedsSave()
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
immediate: false,
|
||||
onError: (error) => {
|
||||
console.error('Failed to save workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('shareWorkflow.saveFailedTitle'),
|
||||
detail: t('shareWorkflow.saveFailedDescription')
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function handlePublishGateComplete() {
|
||||
closeProfileCreationStep()
|
||||
@@ -75,18 +201,67 @@ function handleRequireProfile() {
|
||||
openProfileCreationStep()
|
||||
}
|
||||
|
||||
async function handlePublish(): Promise<void> {
|
||||
if (isPublishing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isPublishing.value = true
|
||||
try {
|
||||
await submitToComfyHub(formData.value)
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path) {
|
||||
cachePublishPrefill(path, formData.value)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to publish workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('comfyHubPublish.publishFailedTitle'),
|
||||
detail: t('comfyHubPublish.publishFailedDescription')
|
||||
})
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
|
||||
formData.value = { ...formData.value, ...patch }
|
||||
}
|
||||
|
||||
async function fetchPublishPrefill() {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return
|
||||
|
||||
try {
|
||||
const status = await shareService.getPublishStatus(path)
|
||||
const prefill = status.isPublished
|
||||
? (status.prefill ?? getCachedPrefill(path))
|
||||
: getCachedPrefill(path)
|
||||
if (prefill) {
|
||||
applyPrefill(prefill)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch publish prefill:', error)
|
||||
const cached = getCachedPrefill(path)
|
||||
if (cached) {
|
||||
applyPrefill(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Prefetch profile data in the background so finish-step profile context is ready.
|
||||
checkNeedsSave()
|
||||
void fetchProfile()
|
||||
void fetchPublishPrefill()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const image of formData.value.exampleImages) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
if (image.file) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<template>
|
||||
<footer class="flex shrink items-center justify-between py-2">
|
||||
<div>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
<footer
|
||||
class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4"
|
||||
>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isLastStep"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled || isPublishing"
|
||||
:loading="isPublishing"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -31,6 +35,7 @@ defineProps<{
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishDisabled?: boolean
|
||||
isPublishing?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="flex flex-col gap-6 px-3 py-4">
|
||||
<ol class="flex flex-col">
|
||||
<ol class="flex list-none flex-col p-0">
|
||||
<li
|
||||
v-for="step in steps"
|
||||
:key="step.name"
|
||||
|
||||
@@ -8,13 +8,20 @@ import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/
|
||||
const mockCheckProfile = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockHasProfile = ref<boolean | null>(true)
|
||||
const mockIsFetchingProfile = ref(false)
|
||||
const mockProfile = ref<{ username: string; name?: string } | null>({
|
||||
username: 'testuser',
|
||||
name: 'Test User'
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
checkProfile: mockCheckProfile,
|
||||
hasProfile: mockHasProfile
|
||||
hasProfile: mockHasProfile,
|
||||
isFetchingProfile: mockIsFetchingProfile,
|
||||
profile: mockProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -39,14 +46,16 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +70,11 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onPublish.mockResolvedValue(undefined)
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
mockHasProfile.value = true
|
||||
mockIsFetchingProfile.value = false
|
||||
mockProfile.value = { username: 'testuser', name: 'Test User' }
|
||||
mockFlags.comfyHubProfileGateEnabled = true
|
||||
})
|
||||
|
||||
@@ -99,9 +111,23 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
template: '<div data-testid="publish-gate-flow" />',
|
||||
props: ['onProfileCreated', 'onClose', 'showCloseButton']
|
||||
},
|
||||
Skeleton: {
|
||||
template: '<div class="skeleton" />'
|
||||
},
|
||||
ComfyHubDescribeStep: {
|
||||
template: '<div data-testid="describe-step" />'
|
||||
},
|
||||
ComfyHubFinishStep: {
|
||||
template: '<div data-testid="finish-step" />',
|
||||
props: ['profile', 'acknowledged', 'ready'],
|
||||
emits: ['update:ready', 'update:acknowledged'],
|
||||
setup(
|
||||
_: unknown,
|
||||
{ emit }: { emit: (e: string, v: boolean) => void }
|
||||
) {
|
||||
emit('update:ready', true)
|
||||
}
|
||||
},
|
||||
ComfyHubExamplesStep: {
|
||||
template: '<div data-testid="examples-step" />'
|
||||
},
|
||||
@@ -115,8 +141,13 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
},
|
||||
ComfyHubPublishFooter: {
|
||||
template:
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: [
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'isPublishDisabled',
|
||||
'isPublishing'
|
||||
],
|
||||
emits: ['publish', 'next', 'back']
|
||||
}
|
||||
}
|
||||
@@ -124,43 +155,19 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('handlePublish — double-click guard', () => {
|
||||
it('prevents concurrent publish calls', async () => {
|
||||
let resolveCheck!: (v: boolean) => void
|
||||
mockCheckProfile.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveCheck = resolve
|
||||
})
|
||||
)
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
let reject: (error: unknown) => void = () => {}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
|
||||
resolveCheck(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — feature flag bypass', () => {
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — profile check routing', () => {
|
||||
describe('handlePublish - profile check routing', () => {
|
||||
it('calls onPublish when profile exists', async () => {
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
|
||||
@@ -197,20 +204,83 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
expect(onRequireProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets guard after checkProfile error so retry is possible', async () => {
|
||||
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish - async submission', () => {
|
||||
it('prevents duplicate publish submissions while in-flight', async () => {
|
||||
const publishDeferred = createDeferred<void>()
|
||||
onPublish.mockReturnValue(publishDeferred.promise)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
|
||||
publishDeferred.resolve(undefined)
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('calls onPublish and does not close when publish request fails', async () => {
|
||||
const publishError = new Error('Publish failed')
|
||||
onPublish.mockRejectedValueOnce(publishError)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
|
||||
expect(onGateClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows publish disabled while submitting', async () => {
|
||||
const publishDeferred = createDeferred<void>()
|
||||
onPublish.mockReturnValue(publishDeferred.promise)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
expect(footer.attributes('data-is-publishing')).toBe('true')
|
||||
|
||||
publishDeferred.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
expect(footer.attributes('data-is-publishing')).toBe('false')
|
||||
})
|
||||
|
||||
it('resets guard after publish error so retry is possible', async () => {
|
||||
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
|
||||
onPublish.mockResolvedValueOnce(undefined)
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,9 +293,10 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('enables publish when gate enabled and hasProfile is true', () => {
|
||||
it('enables publish when gate enabled and hasProfile is true', async () => {
|
||||
mockHasProfile.value = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('false')
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
:on-close="onGateClose"
|
||||
:show-close-button="false"
|
||||
/>
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-2">
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<ComfyHubDescribeStep
|
||||
v-if="currentStep === 'describe'"
|
||||
:name="formData.name"
|
||||
:description="formData.description"
|
||||
:workflow-type="formData.workflowType"
|
||||
:tags="formData.tags"
|
||||
@update:name="onUpdateFormData({ name: $event })"
|
||||
@update:description="onUpdateFormData({ description: $event })"
|
||||
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
|
||||
@update:tags="onUpdateFormData({ tags: $event })"
|
||||
/>
|
||||
<div
|
||||
@@ -37,13 +35,22 @@
|
||||
/>
|
||||
<ComfyHubExamplesStep
|
||||
:example-images="formData.exampleImages"
|
||||
:selected-example-ids="formData.selectedExampleIds"
|
||||
@update:example-images="onUpdateFormData({ exampleImages: $event })"
|
||||
@update:selected-example-ids="
|
||||
onUpdateFormData({ selectedExampleIds: $event })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentStep === 'finish' && isProfileLoading"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"
|
||||
>
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
<Skeleton class="h-20 w-full rounded-2xl" />
|
||||
</div>
|
||||
<ComfyHubFinishStep
|
||||
v-else-if="currentStep === 'finish' && hasProfile && profile"
|
||||
v-model:ready="finishStepReady"
|
||||
v-model:acknowledged="assetsAcknowledged"
|
||||
:profile
|
||||
/>
|
||||
<ComfyHubProfilePromptPanel
|
||||
v-else-if="currentStep === 'finish'"
|
||||
@request-profile="onRequireProfile"
|
||||
@@ -53,6 +60,7 @@
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publish-disabled
|
||||
:is-publishing="isPublishInFlight"
|
||||
@back="onGoBack"
|
||||
@next="onGoNext"
|
||||
@publish="handlePublish"
|
||||
@@ -70,8 +78,10 @@ import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/
|
||||
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
|
||||
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
|
||||
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
|
||||
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
|
||||
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
|
||||
@@ -81,6 +91,7 @@ const {
|
||||
formData,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isPublishing = false,
|
||||
onGoNext,
|
||||
onGoBack,
|
||||
onUpdateFormData,
|
||||
@@ -93,10 +104,11 @@ const {
|
||||
formData: ComfyHubPublishFormData
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishing?: boolean
|
||||
onGoNext: () => void
|
||||
onGoBack: () => void
|
||||
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
|
||||
onPublish: () => void
|
||||
onPublish: () => Promise<void>
|
||||
onRequireProfile: () => void
|
||||
onGateComplete?: () => void
|
||||
onGateClose?: () => void
|
||||
@@ -104,24 +116,42 @@ const {
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { checkProfile, hasProfile } = useComfyHubProfileGate()
|
||||
const { checkProfile, hasProfile, isFetchingProfile, profile } =
|
||||
useComfyHubProfileGate()
|
||||
const isProfileLoading = computed(
|
||||
() => hasProfile.value === null || isFetchingProfile.value
|
||||
)
|
||||
const finishStepReady = ref(false)
|
||||
const assetsAcknowledged = ref(false)
|
||||
const isResolvingPublishAccess = ref(false)
|
||||
const isPublishInFlight = computed(
|
||||
() => isPublishing || isResolvingPublishAccess.value
|
||||
)
|
||||
const isFinishStepVisible = computed(
|
||||
() =>
|
||||
currentStep === 'finish' &&
|
||||
hasProfile.value === true &&
|
||||
profile.value !== null
|
||||
)
|
||||
const isPublishDisabled = computed(
|
||||
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
|
||||
() =>
|
||||
isPublishInFlight.value ||
|
||||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true) ||
|
||||
(isFinishStepVisible.value && !finishStepReady.value)
|
||||
)
|
||||
|
||||
async function handlePublish() {
|
||||
if (isResolvingPublishAccess.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
onPublish()
|
||||
if (isResolvingPublishAccess.value || isPublishing) {
|
||||
return
|
||||
}
|
||||
|
||||
isResolvingPublishAccess.value = true
|
||||
try {
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
await onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
let profileExists: boolean
|
||||
try {
|
||||
profileExists = await checkProfile()
|
||||
@@ -131,11 +161,13 @@ async function handlePublish() {
|
||||
}
|
||||
|
||||
if (profileExists) {
|
||||
onPublish()
|
||||
await onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
onRequireProfile()
|
||||
} catch (error) {
|
||||
toastErrorHandler(error)
|
||||
} finally {
|
||||
isResolvingPublishAccess.value = false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground select-none">
|
||||
{{ $t('comfyHubPublish.selectAThumbnail') }}
|
||||
</legend>
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="thumbnailType"
|
||||
@@ -14,18 +14,19 @@
|
||||
v-for="option in thumbnailOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
|
||||
class="flex h-auto w-full gap-2 rounded-sm bg-node-component-surface p-2 font-inter text-base-foreground data-[state=on]:bg-muted-background"
|
||||
>
|
||||
<span class="text-center text-sm font-bold text-base-foreground">
|
||||
<i :class="cn('size-4', option.icon)" />
|
||||
<span class="text-center text-sm font-bold">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
<span class="text-sm text-base-foreground select-none">
|
||||
{{ uploadSectionLabel }}
|
||||
</span>
|
||||
<Button
|
||||
@@ -40,7 +41,7 @@
|
||||
|
||||
<template v-if="thumbnailType === 'imageComparison'">
|
||||
<div
|
||||
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe"
|
||||
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-if="hasBothComparisonImages"
|
||||
@@ -69,7 +70,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full row-span-full flex gap-2',
|
||||
'col-span-full row-span-full flex items-center-safe justify-center-safe gap-2',
|
||||
hasBothComparisonImages && 'invisible'
|
||||
)
|
||||
"
|
||||
@@ -80,8 +81,10 @@
|
||||
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
|
||||
'flex aspect-square h-full min-h-0 flex-[0_1_auto] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
|
||||
comparisonPreviewUrls[slot.key]
|
||||
? 'self-start'
|
||||
: 'flex-[0_1_1]',
|
||||
comparisonOverStates[slot.key]
|
||||
? 'border-muted-foreground'
|
||||
: 'border-border-default hover:border-muted-foreground'
|
||||
@@ -123,7 +126,7 @@
|
||||
ref="singleDropRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
'm-auto flex aspect-square min-h-0 w-full max-w-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
|
||||
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
|
||||
isOverSingleDrop
|
||||
? 'border-muted-foreground'
|
||||
@@ -239,15 +242,18 @@ const uploadDropText = computed(() =>
|
||||
const thumbnailOptions = [
|
||||
{
|
||||
value: 'image' as const,
|
||||
label: t('comfyHubPublish.thumbnailImage')
|
||||
label: t('comfyHubPublish.thumbnailImage'),
|
||||
icon: 'icon-[lucide--image]'
|
||||
},
|
||||
{
|
||||
value: 'video' as const,
|
||||
label: t('comfyHubPublish.thumbnailVideo')
|
||||
label: t('comfyHubPublish.thumbnailVideo'),
|
||||
icon: 'icon-[lucide--video]'
|
||||
},
|
||||
{
|
||||
value: 'imageComparison' as const,
|
||||
label: t('comfyHubPublish.thumbnailImageComparison')
|
||||
label: t('comfyHubPublish.thumbnailImageComparison'),
|
||||
icon: 'icon-[lucide--diff]'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div
|
||||
ref="tileRef"
|
||||
:class="
|
||||
cn(
|
||||
'group focus-visible:outline-ring relative aspect-square overflow-hidden rounded-sm outline-offset-2 focus-visible:outline-2',
|
||||
state === 'dragging' && 'opacity-40',
|
||||
state === 'over' && 'ring-2 ring-primary'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="listitem"
|
||||
:aria-label="
|
||||
$t('comfyHubPublish.exampleImagePosition', {
|
||||
index: index + 1,
|
||||
total: total
|
||||
})
|
||||
"
|
||||
@pointerdown="tileRef && focusVisible(tileRef)"
|
||||
@keydown.left.prevent="handleArrowKey(-1, $event)"
|
||||
@keydown.right.prevent="handleArrowKey(1, $event)"
|
||||
@keydown.delete.prevent="handleRemove"
|
||||
@keydown.backspace.prevent="handleRemove"
|
||||
@dragover.prevent.stop
|
||||
@drop.prevent.stop="handleFileDrop"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
|
||||
class="pointer-events-none size-full object-cover"
|
||||
draggable="false"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('comfyHubPublish.removeExampleImage')"
|
||||
tabindex="-1"
|
||||
class="absolute top-1 right-1 flex size-6 items-center justify-center bg-black/60 text-white opacity-0 transition-opacity not-group-has-focus-visible/grid:group-hover:opacity-100 group-focus-visible:opacity-100 hover:bg-black/80"
|
||||
@click="$emit('remove', image.id)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { image, index, total, instanceId } = defineProps<{
|
||||
image: ExampleImage
|
||||
index: number
|
||||
total: number
|
||||
instanceId: symbol
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [id: string]
|
||||
move: [id: string, direction: number]
|
||||
insertFiles: [index: number, files: FileList]
|
||||
}>()
|
||||
|
||||
// focusVisible is a Chromium 122+ extension to FocusOptions
|
||||
// (not yet in TypeScript's lib.dom.d.ts)
|
||||
function focusVisible(el: HTMLElement) {
|
||||
el.focus({ focusVisible: true } as FocusOptions)
|
||||
}
|
||||
|
||||
async function handleArrowKey(direction: number, event: KeyboardEvent) {
|
||||
if (event.shiftKey) {
|
||||
emit('move', image.id, direction)
|
||||
await nextTick()
|
||||
if (tileRef.value) focusVisible(tileRef.value)
|
||||
} else {
|
||||
focusSibling(direction)
|
||||
}
|
||||
}
|
||||
|
||||
function focusSibling(direction: number) {
|
||||
const sibling =
|
||||
direction < 0
|
||||
? tileRef.value?.previousElementSibling
|
||||
: tileRef.value?.nextElementSibling
|
||||
if (sibling instanceof HTMLElement) focusVisible(sibling)
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
const next =
|
||||
tileRef.value?.nextElementSibling ?? tileRef.value?.previousElementSibling
|
||||
emit('remove', image.id)
|
||||
await nextTick()
|
||||
if (next instanceof HTMLElement) focusVisible(next)
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
if (event.dataTransfer?.files?.length) {
|
||||
emit('insertFiles', index, event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const tileRef = ref<HTMLElement | null>(null)
|
||||
|
||||
type DragState = 'idle' | 'dragging' | 'over'
|
||||
const state = ref<DragState>('idle')
|
||||
|
||||
const tileGetter = () => tileRef.value as HTMLElement
|
||||
|
||||
usePragmaticDraggable(tileGetter, {
|
||||
getInitialData: () => ({
|
||||
type: 'example-image',
|
||||
imageId: image.id,
|
||||
instanceId
|
||||
}),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
nativeSetDragImage,
|
||||
render: ({ container }) => {
|
||||
const img = tileRef.value?.querySelector('img')
|
||||
if (!img) return
|
||||
const preview = img.cloneNode(true) as HTMLImageElement
|
||||
preview.style.width = '8rem'
|
||||
preview.style.height = '8rem'
|
||||
preview.style.objectFit = 'cover'
|
||||
preview.style.borderRadius = '4px'
|
||||
container.appendChild(preview)
|
||||
}
|
||||
})
|
||||
},
|
||||
onDragStart: () => {
|
||||
state.value = 'dragging'
|
||||
},
|
||||
onDrop: () => {
|
||||
state.value = 'idle'
|
||||
}
|
||||
})
|
||||
|
||||
usePragmaticDroppable(tileGetter, {
|
||||
getData: () => ({ imageId: image.id }),
|
||||
canDrop: ({ source }) =>
|
||||
source.data.instanceId === instanceId &&
|
||||
source.data.type === 'example-image' &&
|
||||
source.data.imageId !== image.id,
|
||||
onDragEnter: () => {
|
||||
state.value = 'over'
|
||||
},
|
||||
onDragLeave: () => {
|
||||
state.value = 'idle'
|
||||
},
|
||||
onDrop: () => {
|
||||
state.value = 'idle'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -2,16 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockGetMyProfile = vi.hoisted(() => vi.fn())
|
||||
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
|
||||
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
|
||||
const mockCreateProfile = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockResolvedUserInfo = vi.hoisted(() => ({
|
||||
value: { id: 'user-a' }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: mockFetchApi
|
||||
}
|
||||
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
|
||||
useComfyHubService: () => ({
|
||||
getMyProfile: mockGetMyProfile,
|
||||
requestAssetUploadUrl: mockRequestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
|
||||
createProfile: mockCreateProfile
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
@@ -35,19 +41,16 @@ const mockProfile: ComfyHubProfile = {
|
||||
description: 'A test profile'
|
||||
}
|
||||
|
||||
function mockSuccessResponse(data?: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => data ?? mockProfile
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mockErrorResponse(status = 500, message = 'Server error') {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ message })
|
||||
} as Response
|
||||
function setCurrentWorkspace(workspaceId: string) {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({
|
||||
id: workspaceId,
|
||||
type: 'team',
|
||||
name: 'Test Workspace',
|
||||
role: 'owner'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('useComfyHubProfileGate', () => {
|
||||
@@ -56,6 +59,15 @@ describe('useComfyHubProfileGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolvedUserInfo.value = { id: 'user-a' }
|
||||
setCurrentWorkspace('workspace-1')
|
||||
mockGetMyProfile.mockResolvedValue(mockProfile)
|
||||
mockRequestAssetUploadUrl.mockResolvedValue({
|
||||
uploadUrl: 'https://upload.example.com/avatar.png',
|
||||
publicUrl: 'https://cdn.example.com/avatar.png',
|
||||
token: 'avatar-token'
|
||||
})
|
||||
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
|
||||
mockCreateProfile.mockResolvedValue(mockProfile)
|
||||
|
||||
// Reset module-level singleton refs
|
||||
gate = useComfyHubProfileGate()
|
||||
@@ -66,50 +78,40 @@ describe('useComfyHubProfileGate', () => {
|
||||
})
|
||||
|
||||
describe('fetchProfile', () => {
|
||||
it('returns mapped profile when API responds ok', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
it('fetches profile from /hub/profiles/me', async () => {
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toEqual(mockProfile)
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
expect(gate.profile.value).toEqual(mockProfile)
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
|
||||
expect(mockGetMyProfile).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns cached profile when already fetched', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
it('reuses cached profile state per user', async () => {
|
||||
await gate.fetchProfile()
|
||||
await gate.fetchProfile()
|
||||
expect(mockGetMyProfile).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockResolvedUserInfo.value = { id: 'user-b' }
|
||||
await gate.fetchProfile()
|
||||
|
||||
expect(mockGetMyProfile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('sets hasProfile to false when fetch throws', async () => {
|
||||
mockGetMyProfile.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await gate.fetchProfile()
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toEqual(mockProfile)
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-fetches profile when force option is enabled', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.fetchProfile()
|
||||
await gate.fetchProfile({ force: true })
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns null when API responds with error', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
|
||||
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toBeNull()
|
||||
expect(gate.hasProfile.value).toBe(false)
|
||||
expect(gate.profile.value).toBeNull()
|
||||
expect(gate.profile.value).toBe(null)
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('sets isFetchingProfile during fetch', async () => {
|
||||
let resolvePromise: (v: Response) => void
|
||||
mockFetchApi.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
let resolvePromise: (v: ComfyHubProfile | null) => void
|
||||
mockGetMyProfile.mockReturnValue(
|
||||
new Promise<ComfyHubProfile | null>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
)
|
||||
@@ -117,7 +119,7 @@ describe('useComfyHubProfileGate', () => {
|
||||
const promise = gate.fetchProfile()
|
||||
expect(gate.isFetchingProfile.value).toBe(true)
|
||||
|
||||
resolvePromise!(mockSuccessResponse())
|
||||
resolvePromise!(mockProfile)
|
||||
await promise
|
||||
|
||||
expect(gate.isFetchingProfile.value).toBe(false)
|
||||
@@ -126,7 +128,7 @@ describe('useComfyHubProfileGate', () => {
|
||||
|
||||
describe('checkProfile', () => {
|
||||
it('returns true when API responds ok', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
mockGetMyProfile.mockResolvedValue(mockProfile)
|
||||
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
@@ -134,105 +136,62 @@ describe('useComfyHubProfileGate', () => {
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when API responds with error', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
|
||||
it('returns false when no profile exists', async () => {
|
||||
mockGetMyProfile.mockResolvedValue(null)
|
||||
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(gate.hasProfile.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns cached value without re-fetching', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.checkProfile()
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears cached profile state when the authenticated user changes', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.checkProfile()
|
||||
mockResolvedUserInfo.value = { id: 'user-b' }
|
||||
await gate.checkProfile()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProfile', () => {
|
||||
it('sends FormData with required username', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
const [url, options] = mockFetchApi.mock.calls[0]
|
||||
expect(url).toBe('/hub/profile')
|
||||
expect(options.method).toBe('POST')
|
||||
|
||||
const body = options.body as FormData
|
||||
expect(body.get('username')).toBe('testuser')
|
||||
})
|
||||
|
||||
it('includes optional fields when provided', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
const coverImage = new File(['img'], 'cover.png')
|
||||
it('creates profile with workspace_id and avatar token', async () => {
|
||||
const profilePicture = new File(['img'], 'avatar.png')
|
||||
|
||||
await gate.createProfile({
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'Hello',
|
||||
coverImage,
|
||||
profilePicture
|
||||
})
|
||||
|
||||
const body = mockFetchApi.mock.calls[0][1].body as FormData
|
||||
expect(body.get('name')).toBe('Test User')
|
||||
expect(body.get('description')).toBe('Hello')
|
||||
expect(body.get('cover_image')).toBe(coverImage)
|
||||
expect(body.get('profile_picture')).toBe(profilePicture)
|
||||
})
|
||||
|
||||
it('sets profile state on success', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
expect(gate.profile.value).toEqual(mockProfile)
|
||||
})
|
||||
|
||||
it('returns the created profile', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockSuccessResponse({
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'A test profile',
|
||||
cover_image_url: 'https://example.com/cover.png',
|
||||
profile_picture_url: 'https://example.com/profile.png'
|
||||
})
|
||||
)
|
||||
|
||||
const profile = await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
expect(profile).toEqual({
|
||||
...mockProfile,
|
||||
coverImageUrl: 'https://example.com/cover.png',
|
||||
profilePictureUrl: 'https://example.com/profile.png'
|
||||
expect(mockCreateProfile).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
username: 'testuser',
|
||||
displayName: 'Test User',
|
||||
description: 'Hello',
|
||||
avatarToken: 'avatar-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws with error message from API response', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
|
||||
it('uploads avatar via upload-url + PUT before create', async () => {
|
||||
const profilePicture = new File(['img'], 'avatar.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
|
||||
'Username taken'
|
||||
)
|
||||
await gate.createProfile({
|
||||
username: 'testuser',
|
||||
profilePicture
|
||||
})
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
|
||||
filename: 'avatar.png',
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
|
||||
uploadUrl: 'https://upload.example.com/avatar.png',
|
||||
file: profilePicture,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
const requestCallOrder =
|
||||
mockRequestAssetUploadUrl.mock.invocationCallOrder
|
||||
const uploadCallOrder =
|
||||
mockUploadFileToPresignedUrl.mock.invocationCallOrder
|
||||
const createCallOrder = mockCreateProfile.mock.invocationCallOrder
|
||||
expect(requestCallOrder[0]).toBeLessThan(uploadCallOrder[0])
|
||||
expect(uploadCallOrder[0]).toBeLessThan(createCallOrder[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ref } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// TODO: Migrate to a Pinia store for proper singleton state management
|
||||
// User-scoped, session-cached profile state (module-level singleton)
|
||||
@@ -15,14 +15,43 @@ const profile = ref<ComfyHubProfile | null>(null)
|
||||
const cachedUserId = ref<string | null>(null)
|
||||
let inflightFetch: Promise<ComfyHubProfile | null> | null = null
|
||||
|
||||
function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
|
||||
const result = zHubProfileResponse.safeParse(payload)
|
||||
return result.success ? result.data : null
|
||||
function getCurrentWorkspaceId(): string {
|
||||
const workspaceJson = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
|
||||
)
|
||||
if (!workspaceJson) {
|
||||
throw new Error('Unable to determine current workspace')
|
||||
}
|
||||
|
||||
let workspace: unknown
|
||||
try {
|
||||
workspace = JSON.parse(workspaceJson)
|
||||
} catch {
|
||||
throw new Error('Unable to determine current workspace')
|
||||
}
|
||||
|
||||
if (
|
||||
!workspace ||
|
||||
typeof workspace !== 'object' ||
|
||||
!('id' in workspace) ||
|
||||
typeof workspace.id !== 'string' ||
|
||||
workspace.id.length === 0
|
||||
) {
|
||||
throw new Error('Unable to determine current workspace')
|
||||
}
|
||||
|
||||
return workspace.id
|
||||
}
|
||||
|
||||
export function useComfyHubProfileGate() {
|
||||
const { resolvedUserInfo } = useCurrentUser()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const {
|
||||
getMyProfile,
|
||||
requestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl,
|
||||
createProfile: createComfyHubProfile
|
||||
} = useComfyHubService()
|
||||
|
||||
function syncCachedProfileWithCurrentUser(): void {
|
||||
const currentUserId = resolvedUserInfo.value?.id ?? null
|
||||
@@ -38,14 +67,7 @@ export function useComfyHubProfileGate() {
|
||||
async function performFetch(): Promise<ComfyHubProfile | null> {
|
||||
isFetchingProfile.value = true
|
||||
try {
|
||||
const response = await api.fetchApi('/hub/profile')
|
||||
if (!response.ok) {
|
||||
hasProfile.value = false
|
||||
profile.value = null
|
||||
return null
|
||||
}
|
||||
|
||||
const nextProfile = mapHubProfileResponse(await response.json())
|
||||
const nextProfile = await getMyProfile()
|
||||
if (!nextProfile) {
|
||||
hasProfile.value = false
|
||||
profile.value = null
|
||||
@@ -55,6 +77,7 @@ export function useComfyHubProfileGate() {
|
||||
profile.value = nextProfile
|
||||
return nextProfile
|
||||
} catch (error) {
|
||||
hasProfile.value = false
|
||||
toastErrorHandler(error)
|
||||
return null
|
||||
} finally {
|
||||
@@ -95,37 +118,35 @@ export function useComfyHubProfileGate() {
|
||||
username: string
|
||||
name?: string
|
||||
description?: string
|
||||
coverImage?: File
|
||||
profilePicture?: File
|
||||
}): Promise<ComfyHubProfile> {
|
||||
syncCachedProfileWithCurrentUser()
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('username', data.username)
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.coverImage) formData.append('cover_image', data.coverImage)
|
||||
if (data.profilePicture)
|
||||
formData.append('profile_picture', data.profilePicture)
|
||||
let avatarToken: string | undefined
|
||||
if (data.profilePicture) {
|
||||
const contentType = data.profilePicture.type || 'application/octet-stream'
|
||||
const upload = await requestAssetUploadUrl({
|
||||
filename: data.profilePicture.name,
|
||||
contentType
|
||||
})
|
||||
|
||||
const response = await api.fetchApi('/hub/profile', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
await uploadFileToPresignedUrl({
|
||||
uploadUrl: upload.uploadUrl,
|
||||
file: data.profilePicture,
|
||||
contentType
|
||||
})
|
||||
|
||||
avatarToken = upload.token
|
||||
}
|
||||
|
||||
const createdProfile = await createComfyHubProfile({
|
||||
workspaceId: getCurrentWorkspaceId(),
|
||||
username: data.username,
|
||||
displayName: data.name,
|
||||
description: data.description,
|
||||
avatarToken
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch(() => ({}))
|
||||
const message =
|
||||
body && typeof body === 'object' && 'message' in body
|
||||
? String((body as Record<string, unknown>).message)
|
||||
: 'Failed to create profile'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const createdProfile = mapHubProfileResponse(await response.json())
|
||||
if (!createdProfile) {
|
||||
throw new Error('Invalid profile response from server')
|
||||
}
|
||||
hasProfile.value = true
|
||||
profile.value = createdProfile
|
||||
return createdProfile
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
|
||||
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
|
||||
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
|
||||
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockProfile = vi.hoisted(
|
||||
() => ({ value: null }) as { value: ComfyHubProfile | null }
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
profile: mockProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getShareableAssets: mockGetShareableAssets
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
|
||||
useComfyHubService: () => ({
|
||||
requestAssetUploadUrl: mockRequestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
|
||||
publishWorkflow: mockPublishWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/demo-workflow.json'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
const { useComfyHubPublishSubmission } =
|
||||
await import('./useComfyHubPublishSubmission')
|
||||
|
||||
function createFormData(
|
||||
overrides: Partial<ComfyHubPublishFormData> = {}
|
||||
): ComfyHubPublishFormData {
|
||||
return {
|
||||
name: 'Demo workflow',
|
||||
description: 'A demo workflow',
|
||||
tags: ['demo'],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useComfyHubPublishSubmission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProfile.value = {
|
||||
username: 'builder',
|
||||
name: 'Builder'
|
||||
}
|
||||
mockGetShareableAssets.mockResolvedValue([
|
||||
{ id: 'asset-1' },
|
||||
{ id: 'asset-2' }
|
||||
])
|
||||
|
||||
let uploadIndex = 0
|
||||
mockRequestAssetUploadUrl.mockImplementation(
|
||||
async ({ filename }: { filename: string }) => {
|
||||
uploadIndex += 1
|
||||
return {
|
||||
uploadUrl: `https://upload.example.com/${filename}`,
|
||||
publicUrl: `https://cdn.example.com/${filename}`,
|
||||
token: `token-${uploadIndex}`
|
||||
}
|
||||
}
|
||||
)
|
||||
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
|
||||
mockPublishWorkflow.mockResolvedValue({
|
||||
share_id: 'share-1',
|
||||
workflow_id: 'workflow-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes imageComparison thumbnail type to service for normalization', async () => {
|
||||
const beforeFile = new File(['before'], 'before.png', { type: 'image/png' })
|
||||
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: beforeFile,
|
||||
comparisonAfterFile: afterFile
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailType: 'imageComparison'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads thumbnail and returns thumbnail token', async () => {
|
||||
const thumbnailFile = new File(['thumbnail'], 'thumb.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
|
||||
filename: 'thumb.png',
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
|
||||
uploadUrl: 'https://upload.example.com/thumb.png',
|
||||
file: thumbnailFile,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'token-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads all example images', async () => {
|
||||
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
|
||||
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
exampleImages: [
|
||||
{ id: 'a', file: file1, url: 'blob:a' },
|
||||
{ id: 'b', file: file2, url: 'blob:b' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(2)
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sampleImageTokensOrUrls: ['token-1', 'token-2']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('builds publish request with workflow filename + asset ids', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(createFormData())
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'builder',
|
||||
workflowFilename: 'workflows/demo-workflow.json',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
name: 'Demo workflow',
|
||||
description: 'A demo workflow',
|
||||
tags: ['demo']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when profile username is unavailable', async () => {
|
||||
mockProfile.value = null
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await expect(submitToComfyHub(createFormData())).rejects.toThrow(
|
||||
'ComfyHub profile is required before publishing'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { AssetInfo, ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
|
||||
|
||||
function getFileContentType(file: File): string {
|
||||
return file.type || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function getUsername(profile: ComfyHubProfile | null): string {
|
||||
const username = profile?.username?.trim()
|
||||
if (!username) {
|
||||
throw new Error('ComfyHub profile is required before publishing')
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
function getWorkflowFilename(path: string | null | undefined): string {
|
||||
const workflowFilename = path?.trim()
|
||||
if (!workflowFilename) {
|
||||
throw new Error('No active workflow file available for publishing')
|
||||
}
|
||||
|
||||
return workflowFilename
|
||||
}
|
||||
|
||||
function getAssetIds(assets: AssetInfo[]): string[] {
|
||||
return assets.map((asset) => asset.id)
|
||||
}
|
||||
|
||||
function resolveThumbnailFile(
|
||||
formData: ComfyHubPublishFormData
|
||||
): File | undefined {
|
||||
if (formData.thumbnailType === 'imageComparison') {
|
||||
return formData.comparisonBeforeFile ?? undefined
|
||||
}
|
||||
return formData.thumbnailFile ?? undefined
|
||||
}
|
||||
|
||||
export function useComfyHubPublishSubmission() {
|
||||
const { profile } = useComfyHubProfileGate()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const comfyHubService = useComfyHubService()
|
||||
|
||||
async function uploadFileAndGetToken(file: File): Promise<string> {
|
||||
const contentType = getFileContentType(file)
|
||||
const upload = await comfyHubService.requestAssetUploadUrl({
|
||||
filename: file.name,
|
||||
contentType
|
||||
})
|
||||
|
||||
await comfyHubService.uploadFileToPresignedUrl({
|
||||
uploadUrl: upload.uploadUrl,
|
||||
file,
|
||||
contentType
|
||||
})
|
||||
|
||||
return upload.token
|
||||
}
|
||||
|
||||
async function submitToComfyHub(
|
||||
formData: ComfyHubPublishFormData
|
||||
): Promise<void> {
|
||||
const username = getUsername(profile.value)
|
||||
const workflowFilename = getWorkflowFilename(
|
||||
workflowStore.activeWorkflow?.path
|
||||
)
|
||||
const assetIds = getAssetIds(
|
||||
await workflowShareService.getShareableAssets()
|
||||
)
|
||||
|
||||
const thumbnailFile = resolveThumbnailFile(formData)
|
||||
const thumbnailTokenOrUrl = thumbnailFile
|
||||
? await uploadFileAndGetToken(thumbnailFile)
|
||||
: undefined
|
||||
const thumbnailComparisonTokenOrUrl =
|
||||
formData.thumbnailType === 'imageComparison' &&
|
||||
formData.comparisonAfterFile
|
||||
? await uploadFileAndGetToken(formData.comparisonAfterFile)
|
||||
: undefined
|
||||
|
||||
const sampleImageTokensOrUrls =
|
||||
formData.exampleImages.length > 0
|
||||
? await Promise.all(
|
||||
formData.exampleImages.map((image) =>
|
||||
image.file ? uploadFileAndGetToken(image.file) : image.url
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
|
||||
await comfyHubService.publishWorkflow({
|
||||
username,
|
||||
name: formData.name,
|
||||
workflowFilename,
|
||||
assetIds,
|
||||
description: formData.description || undefined,
|
||||
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
|
||||
models: formData.models.length > 0 ? formData.models : undefined,
|
||||
customNodes:
|
||||
formData.customNodes.length > 0 ? formData.customNodes : undefined,
|
||||
thumbnailType: formData.thumbnailType,
|
||||
thumbnailTokenOrUrl,
|
||||
thumbnailComparisonTokenOrUrl,
|
||||
sampleImageTokensOrUrls,
|
||||
tutorialUrl: formData.tutorialUrl || undefined,
|
||||
metadata:
|
||||
Object.keys(formData.metadata).length > 0
|
||||
? formData.metadata
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
submitToComfyHub
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,12 @@ describe('useComfyHubPublishWizard', () => {
|
||||
it('initialises all other form fields to defaults', () => {
|
||||
const { formData } = useComfyHubPublishWizard()
|
||||
expect(formData.value.description).toBe('')
|
||||
expect(formData.value.workflowType).toBe('')
|
||||
expect(formData.value.tags).toEqual([])
|
||||
expect(formData.value.thumbnailType).toBe('image')
|
||||
expect(formData.value.thumbnailFile).toBeNull()
|
||||
expect(formData.value.comparisonBeforeFile).toBeNull()
|
||||
expect(formData.value.comparisonAfterFile).toBeNull()
|
||||
expect(formData.value.exampleImages).toEqual([])
|
||||
expect(formData.value.selectedExampleIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useStepper } from '@vueuse/core'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type {
|
||||
ComfyHubPublishFormData,
|
||||
ExampleImage
|
||||
} from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { PublishPrefill } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
|
||||
|
||||
const PUBLISH_STEPS = [
|
||||
'describe',
|
||||
@@ -13,22 +19,55 @@ const PUBLISH_STEPS = [
|
||||
|
||||
export type ComfyHubPublishStep = (typeof PUBLISH_STEPS)[number]
|
||||
|
||||
// TODO: Migrate to a Pinia store alongside the profile gate singleton
|
||||
const cachedPrefills = new Map<string, PublishPrefill>()
|
||||
|
||||
function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
const { activeWorkflow } = useWorkflowStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
name: activeWorkflow?.filename ?? '',
|
||||
name: workflowStore.activeWorkflow?.filename ?? '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
}
|
||||
|
||||
function createExampleImagesFromUrls(urls: string[]): ExampleImage[] {
|
||||
return urls.map((url) => ({ id: uuidv4(), url }))
|
||||
}
|
||||
|
||||
function extractPrefillFromFormData(
|
||||
formData: ComfyHubPublishFormData
|
||||
): PublishPrefill {
|
||||
return {
|
||||
description: formData.description || undefined,
|
||||
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
|
||||
thumbnailType: formData.thumbnailType,
|
||||
sampleImageUrls: formData.exampleImages
|
||||
.map((img) => img.url)
|
||||
.filter((url) => !url.startsWith('blob:'))
|
||||
}
|
||||
}
|
||||
|
||||
export function cachePublishPrefill(
|
||||
workflowPath: string,
|
||||
formData: ComfyHubPublishFormData
|
||||
) {
|
||||
cachedPrefills.set(workflowPath, extractPrefillFromFormData(formData))
|
||||
}
|
||||
|
||||
export function getCachedPrefill(workflowPath: string): PublishPrefill | null {
|
||||
return cachedPrefills.get(workflowPath) ?? null
|
||||
}
|
||||
|
||||
export function useComfyHubPublishWizard() {
|
||||
const stepper = useStepper([...PUBLISH_STEPS])
|
||||
const formData = ref<ComfyHubPublishFormData>(createDefaultFormData())
|
||||
@@ -53,6 +92,30 @@ export function useComfyHubPublishWizard() {
|
||||
stepper.goTo('finish')
|
||||
}
|
||||
|
||||
function applyPrefill(prefill: PublishPrefill) {
|
||||
const defaults = createDefaultFormData()
|
||||
const current = formData.value
|
||||
formData.value = {
|
||||
...current,
|
||||
description:
|
||||
current.description === defaults.description
|
||||
? (prefill.description ?? current.description)
|
||||
: current.description,
|
||||
tags:
|
||||
current.tags.length === 0 && prefill.tags?.length
|
||||
? prefill.tags
|
||||
: current.tags,
|
||||
thumbnailType:
|
||||
current.thumbnailType === defaults.thumbnailType
|
||||
? (prefill.thumbnailType ?? current.thumbnailType)
|
||||
: current.thumbnailType,
|
||||
exampleImages:
|
||||
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
|
||||
? createExampleImagesFromUrls(prefill.sampleImageUrls)
|
||||
: current.exampleImages
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep: stepper.current,
|
||||
formData,
|
||||
@@ -64,6 +127,7 @@ export function useComfyHubPublishWizard() {
|
||||
goNext: stepper.goToNext,
|
||||
goBack: stepper.goToPrevious,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
closeProfileCreationStep,
|
||||
applyPrefill
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ const COMFY_HUB_TAG_FREQUENCIES = [
|
||||
{ tag: 'Lip Sync', count: 2 },
|
||||
{ tag: 'Multiple Angles', count: 2 },
|
||||
{ tag: 'Remove Background', count: 2 },
|
||||
{ tag: 'Text-to-Image', count: 2 },
|
||||
{ tag: 'Vector', count: 2 },
|
||||
{ tag: 'Brand', count: 1 },
|
||||
{ tag: 'Canny', count: 1 },
|
||||
|
||||
@@ -10,6 +10,15 @@ export const zPublishRecordResponse = z.object({
|
||||
assets: z.array(zAssetInfo).optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowPrefillResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
sample_image_urls: z.array(z.string()).nullish(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
|
||||
thumbnail_url: z.string().nullish(),
|
||||
thumbnail_comparison_url: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* Strips path separators and control characters from a workflow name to prevent
|
||||
* path traversal when the name is later used as part of a file path.
|
||||
@@ -36,9 +45,28 @@ export const zHubProfileResponse = z.preprocess((data) => {
|
||||
const d = data as Record<string, unknown>
|
||||
return {
|
||||
username: d.username,
|
||||
name: d.name,
|
||||
name: d.name ?? d.display_name,
|
||||
description: d.description,
|
||||
coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
|
||||
profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
|
||||
profilePictureUrl:
|
||||
d.profilePictureUrl ?? d.profile_picture_url ?? d.avatar_url
|
||||
}
|
||||
}, zComfyHubProfile)
|
||||
|
||||
export const zHubAssetUploadUrlResponse = z
|
||||
.object({
|
||||
upload_url: z.string(),
|
||||
public_url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
.transform((response) => ({
|
||||
uploadUrl: response.upload_url,
|
||||
publicUrl: response.public_url,
|
||||
token: response.token
|
||||
}))
|
||||
|
||||
export const zHubWorkflowPublishResponse = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional()
|
||||
})
|
||||
|
||||
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal file
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockGlobalFetch = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
const { useComfyHubService } = await import('./comfyHubService')
|
||||
|
||||
function mockJsonResponse(payload: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => payload
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mockUploadResponse(ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => ({})
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useComfyHubService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.stubGlobal('fetch', mockGlobalFetch)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('requests upload url and returns token payload', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
upload_url: 'https://upload.example.com/object',
|
||||
public_url: 'https://cdn.example.com/object',
|
||||
token: 'upload-token'
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
const result = await service.requestAssetUploadUrl({
|
||||
filename: 'thumb.png',
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/assets/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: 'thumb.png',
|
||||
content_type: 'image/png'
|
||||
})
|
||||
})
|
||||
expect(result).toEqual({
|
||||
uploadUrl: 'https://upload.example.com/object',
|
||||
publicUrl: 'https://cdn.example.com/object',
|
||||
token: 'upload-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads file to presigned url with PUT', async () => {
|
||||
mockGlobalFetch.mockResolvedValue(mockUploadResponse())
|
||||
|
||||
const service = useComfyHubService()
|
||||
const file = new File(['payload'], 'avatar.png', { type: 'image/png' })
|
||||
await service.uploadFileToPresignedUrl({
|
||||
uploadUrl: 'https://upload.example.com/object',
|
||||
file,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
expect(mockGlobalFetch).toHaveBeenCalledWith(
|
||||
'https://upload.example.com/object',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'image/png'
|
||||
},
|
||||
body: file
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('creates profile with workspace_id JSON body', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
id: 'profile-1',
|
||||
username: 'builder',
|
||||
display_name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatar_url: 'https://cdn.example.com/avatar.png',
|
||||
website_urls: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
const profile = await service.createProfile({
|
||||
workspaceId: 'workspace-1',
|
||||
username: 'builder',
|
||||
displayName: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatarToken: 'avatar-token'
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspace_id: 'workspace-1',
|
||||
username: 'builder',
|
||||
display_name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatar_token: 'avatar-token'
|
||||
})
|
||||
})
|
||||
expect(profile).toEqual({
|
||||
username: 'builder',
|
||||
name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
profilePictureUrl: 'https://cdn.example.com/avatar.png',
|
||||
coverImageUrl: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes workflow with mapped thumbnail enum', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
share_id: 'share-1',
|
||||
workflow_id: 'workflow-1',
|
||||
thumbnail_type: 'image_comparison'
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
await service.publishWorkflow({
|
||||
username: 'builder',
|
||||
name: 'My Flow',
|
||||
workflowFilename: 'workflows/my-flow.json',
|
||||
assetIds: ['asset-1'],
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailTokenOrUrl: 'thumb-token',
|
||||
thumbnailComparisonTokenOrUrl: 'thumb-compare-token',
|
||||
sampleImageTokensOrUrls: ['sample-1']
|
||||
})
|
||||
|
||||
const [, options] = mockFetchApi.mock.calls[0]
|
||||
const body = JSON.parse(options.body as string)
|
||||
expect(body).toMatchObject({
|
||||
username: 'builder',
|
||||
name: 'My Flow',
|
||||
workflow_filename: 'workflows/my-flow.json',
|
||||
asset_ids: ['asset-1'],
|
||||
thumbnail_type: 'image_comparison',
|
||||
thumbnail_token_or_url: 'thumb-token',
|
||||
thumbnail_comparison_token_or_url: 'thumb-compare-token',
|
||||
sample_image_tokens_or_urls: ['sample-1']
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches current profile from /hub/profiles/me', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
id: 'profile-1',
|
||||
username: 'builder',
|
||||
display_name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatar_url: 'https://cdn.example.com/avatar.png',
|
||||
website_urls: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
const profile = await service.getMyProfile()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles/me')
|
||||
expect(profile).toEqual({
|
||||
username: 'builder',
|
||||
name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
profilePictureUrl: 'https://cdn.example.com/avatar.png',
|
||||
coverImageUrl: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
223
src/platform/workflow/sharing/services/comfyHubService.ts
Normal file
223
src/platform/workflow/sharing/services/comfyHubService.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import {
|
||||
zHubAssetUploadUrlResponse,
|
||||
zHubProfileResponse,
|
||||
zHubWorkflowPublishResponse
|
||||
} from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
type HubThumbnailType = 'image' | 'video' | 'image_comparison'
|
||||
|
||||
type ThumbnailTypeInput = HubThumbnailType | 'imageComparison'
|
||||
|
||||
interface CreateProfileInput {
|
||||
workspaceId: string
|
||||
username: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
avatarToken?: string
|
||||
}
|
||||
|
||||
interface PublishWorkflowInput {
|
||||
username: string
|
||||
name: string
|
||||
workflowFilename: string
|
||||
assetIds: string[]
|
||||
description?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
customNodes?: string[]
|
||||
thumbnailType?: ThumbnailTypeInput
|
||||
thumbnailTokenOrUrl?: string
|
||||
thumbnailComparisonTokenOrUrl?: string
|
||||
sampleImageTokensOrUrls?: string[]
|
||||
tutorialUrl?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeThumbnailType(type: ThumbnailTypeInput): HubThumbnailType {
|
||||
if (type === 'imageComparison') {
|
||||
return 'image_comparison'
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
async function parseErrorMessage(
|
||||
response: Response,
|
||||
fallbackMessage: string
|
||||
): Promise<string> {
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object') {
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
if ('message' in body && typeof body.message === 'string') {
|
||||
return body.message
|
||||
}
|
||||
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
async function parseRequiredJson<T>(
|
||||
response: Response,
|
||||
parser: {
|
||||
safeParse: (
|
||||
value: unknown
|
||||
) => { success: true; data: T } | { success: false }
|
||||
},
|
||||
fallbackMessage: string
|
||||
): Promise<T> {
|
||||
const payload = await response.json().catch(() => null)
|
||||
const parsed = parser.safeParse(payload)
|
||||
if (!parsed.success) {
|
||||
throw new Error(fallbackMessage)
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function useComfyHubService() {
|
||||
async function requestAssetUploadUrl(input: {
|
||||
filename: string
|
||||
contentType: string
|
||||
}) {
|
||||
const response = await api.fetchApi('/hub/assets/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: input.filename,
|
||||
content_type: input.contentType
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to request upload URL')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubAssetUploadUrlResponse,
|
||||
'Invalid upload URL response from server'
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadFileToPresignedUrl(input: {
|
||||
uploadUrl: string
|
||||
file: File
|
||||
contentType: string
|
||||
}): Promise<void> {
|
||||
const response = await fetch(input.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': input.contentType
|
||||
},
|
||||
body: input.file
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseErrorMessage(
|
||||
response,
|
||||
'Failed to upload file to presigned URL'
|
||||
)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function getMyProfile(): Promise<ComfyHubProfile | null> {
|
||||
const response = await api.fetchApi('/hub/profiles/me')
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to load ComfyHub profile')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubProfileResponse,
|
||||
'Invalid profile response from server'
|
||||
)
|
||||
}
|
||||
|
||||
async function createProfile(
|
||||
input: CreateProfileInput
|
||||
): Promise<ComfyHubProfile> {
|
||||
const response = await api.fetchApi('/hub/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspace_id: input.workspaceId,
|
||||
username: input.username,
|
||||
display_name: input.displayName,
|
||||
description: input.description,
|
||||
avatar_token: input.avatarToken
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to create ComfyHub profile')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubProfileResponse,
|
||||
'Invalid profile response from server'
|
||||
)
|
||||
}
|
||||
|
||||
async function publishWorkflow(input: PublishWorkflowInput) {
|
||||
const body = {
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
workflow_filename: input.workflowFilename,
|
||||
asset_ids: input.assetIds,
|
||||
description: input.description,
|
||||
tags: input.tags,
|
||||
models: input.models,
|
||||
custom_nodes: input.customNodes,
|
||||
thumbnail_type: input.thumbnailType
|
||||
? normalizeThumbnailType(input.thumbnailType)
|
||||
: undefined,
|
||||
thumbnail_token_or_url: input.thumbnailTokenOrUrl,
|
||||
thumbnail_comparison_token_or_url: input.thumbnailComparisonTokenOrUrl,
|
||||
sample_image_tokens_or_urls: input.sampleImageTokensOrUrls,
|
||||
tutorial_url: input.tutorialUrl,
|
||||
metadata: input.metadata
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/hub/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to publish workflow')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubWorkflowPublishResponse,
|
||||
'Invalid publish response from server'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
requestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl,
|
||||
getMyProfile,
|
||||
createProfile,
|
||||
publishWorkflow
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,82 @@ describe(useWorkflowShareService, () => {
|
||||
expect(status.publishedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('includes prefill data from hub workflow details', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-prefill/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-prefill',
|
||||
share_id: 'wf-prefill',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-prefill') {
|
||||
return mockJsonResponse({
|
||||
description: 'A cool workflow',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnail_type: 'image_comparison',
|
||||
sample_image_urls: ['https://example.com/img1.png']
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-prefill')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toEqual({
|
||||
description: 'A cool workflow',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'imageComparison',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenNthCalledWith(2, '/hub/workflows/wf-prefill')
|
||||
})
|
||||
|
||||
it('returns null prefill when hub workflow details are unavailable', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-no-meta/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-no-meta',
|
||||
share_id: 'wf-no-meta',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 500)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-no-meta')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fetch hub workflow details when publish record is unlisted', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'wf-unlisted',
|
||||
share_id: 'wf-unlisted',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-unlisted')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toBeNull()
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/userdata/wf-unlisted/publish')
|
||||
})
|
||||
|
||||
it('preserves app subpath when normalizing publish status share URLs', async () => {
|
||||
window.history.replaceState({}, '', '/comfy/subpath/')
|
||||
mockFetchApi.mockResolvedValue(
|
||||
@@ -303,7 +379,8 @@ describe(useWorkflowShareService, () => {
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type {
|
||||
PublishPrefill,
|
||||
SharedWorkflowPayload,
|
||||
WorkflowPublishResult,
|
||||
WorkflowPublishStatus
|
||||
} from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import {
|
||||
zHubWorkflowPrefillResponse,
|
||||
zPublishRecordResponse,
|
||||
zSharedWorkflowResponse
|
||||
} from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
@@ -28,6 +31,45 @@ class SharedWorkflowLoadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiThumbnailType(
|
||||
value: 'image' | 'video' | 'image_comparison' | null | undefined
|
||||
): ThumbnailType | undefined {
|
||||
if (!value) return undefined
|
||||
if (value === 'image_comparison') return 'imageComparison'
|
||||
return value
|
||||
}
|
||||
|
||||
interface PrefillMetadataFields {
|
||||
description?: string | null
|
||||
tags?: string[] | null
|
||||
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
|
||||
sample_image_urls?: string[] | null
|
||||
}
|
||||
|
||||
function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
|
||||
const description = fields.description ?? undefined
|
||||
const tags = fields.tags ?? undefined
|
||||
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
|
||||
const sampleImageUrls = fields.sample_image_urls ?? undefined
|
||||
|
||||
if (
|
||||
!description &&
|
||||
!tags?.length &&
|
||||
!thumbnailType &&
|
||||
!sampleImageUrls?.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { description, tags, thumbnailType, sampleImageUrls }
|
||||
}
|
||||
|
||||
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {
|
||||
const result = zHubWorkflowPrefillResponse.safeParse(payload)
|
||||
if (!result.success) return null
|
||||
return extractPrefill(result.data)
|
||||
}
|
||||
|
||||
function decodePublishRecord(payload: unknown) {
|
||||
const result = zPublishRecordResponse.safeParse(payload)
|
||||
if (!result.success) return null
|
||||
@@ -37,7 +79,8 @@ function decodePublishRecord(payload: unknown) {
|
||||
shareId: r.share_id ?? undefined,
|
||||
listed: r.listed,
|
||||
publishedAt: parsePublishedAt(r.publish_time),
|
||||
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined
|
||||
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined,
|
||||
prefill: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +124,27 @@ const UNPUBLISHED = {
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
} as const satisfies WorkflowPublishStatus
|
||||
|
||||
export function useWorkflowShareService() {
|
||||
async function fetchHubWorkflowPrefill(
|
||||
shareId: string
|
||||
): Promise<PublishPrefill | null> {
|
||||
const response = await api.fetchApi(
|
||||
`/hub/workflows/${encodeURIComponent(shareId)}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch hub workflow details: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const prefill = decodeHubWorkflowPrefill(await response.json())
|
||||
return prefill
|
||||
}
|
||||
|
||||
async function publishWorkflow(
|
||||
workflowPath: string,
|
||||
shareableAssets: AssetInfo[]
|
||||
@@ -132,11 +192,21 @@ export function useWorkflowShareService() {
|
||||
const record = decodePublishRecord(json)
|
||||
if (!record || !record.shareId || !record.publishedAt) return UNPUBLISHED
|
||||
|
||||
let prefill: PublishPrefill | null = record.prefill
|
||||
if (!prefill && record.listed) {
|
||||
try {
|
||||
prefill = await fetchHubWorkflowPrefill(record.shareId)
|
||||
} catch {
|
||||
prefill = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPublished: true,
|
||||
shareId: record.shareId,
|
||||
shareUrl: normalizeShareUrl(record.shareId),
|
||||
publishedAt: record.publishedAt
|
||||
publishedAt: record.publishedAt,
|
||||
prefill
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
|
||||
|
||||
export type ComfyHubWorkflowType =
|
||||
| 'imageGeneration'
|
||||
| 'videoGeneration'
|
||||
| 'upscaling'
|
||||
| 'editing'
|
||||
|
||||
export interface ExampleImage {
|
||||
id: string
|
||||
url: string
|
||||
@@ -15,12 +9,14 @@ export interface ExampleImage {
|
||||
export interface ComfyHubPublishFormData {
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
models: string[]
|
||||
customNodes: string[]
|
||||
thumbnailType: ThumbnailType
|
||||
thumbnailFile: File | null
|
||||
comparisonBeforeFile: File | null
|
||||
comparisonAfterFile: File | null
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
tutorialUrl: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
export interface WorkflowPublishResult {
|
||||
publishedAt: Date
|
||||
@@ -7,13 +8,27 @@ export interface WorkflowPublishResult {
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export interface PublishPrefill {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
thumbnailType?: ThumbnailType
|
||||
sampleImageUrls?: string[]
|
||||
}
|
||||
|
||||
export type WorkflowPublishStatus =
|
||||
| { isPublished: false; publishedAt: null; shareId: null; shareUrl: null }
|
||||
| {
|
||||
isPublished: false
|
||||
publishedAt: null
|
||||
shareId: null
|
||||
shareUrl: null
|
||||
prefill: null
|
||||
}
|
||||
| {
|
||||
isPublished: true
|
||||
publishedAt: Date
|
||||
shareId: string
|
||||
shareUrl: string
|
||||
prefill: PublishPrefill | null
|
||||
}
|
||||
|
||||
export interface SharedWorkflowPayload {
|
||||
|
||||
55
src/platform/workflow/sharing/utils/normalizeTags.test.ts
Normal file
55
src/platform/workflow/sharing/utils/normalizeTags.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeTag, normalizeTags } from './normalizeTags'
|
||||
|
||||
describe('normalizeTag', () => {
|
||||
it.for([
|
||||
{ input: 'Text to Image', expected: 'text-to-image', name: 'spaces' },
|
||||
{ input: 'API', expected: 'api', name: 'single word' },
|
||||
{
|
||||
input: 'text-to-image',
|
||||
expected: 'text-to-image',
|
||||
name: 'already normalized'
|
||||
},
|
||||
{
|
||||
input: 'Image Upscale',
|
||||
expected: 'image-upscale',
|
||||
name: 'multiple spaces'
|
||||
},
|
||||
{
|
||||
input: ' Video ',
|
||||
expected: 'video',
|
||||
name: 'leading/trailing whitespace'
|
||||
},
|
||||
{ input: ' ', expected: '', name: 'whitespace-only' }
|
||||
])('$name: "$input" → "$expected"', ({ input, expected }) => {
|
||||
expect(normalizeTag(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTags', () => {
|
||||
it.for([
|
||||
{
|
||||
name: 'normalizes all tags',
|
||||
input: ['Text to Image', 'API', 'Video'],
|
||||
expected: ['text-to-image', 'api', 'video']
|
||||
},
|
||||
{
|
||||
name: 'deduplicates tags with the same slug',
|
||||
input: ['Text to Image', 'Text-to-Image'],
|
||||
expected: ['text-to-image']
|
||||
},
|
||||
{
|
||||
name: 'filters out empty tags',
|
||||
input: ['Video', '', ' ', 'Audio'],
|
||||
expected: ['video', 'audio']
|
||||
},
|
||||
{
|
||||
name: 'returns empty array for empty input',
|
||||
input: [],
|
||||
expected: []
|
||||
}
|
||||
])('$name', ({ input, expected }) => {
|
||||
expect(normalizeTags(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
14
src/platform/workflow/sharing/utils/normalizeTags.ts
Normal file
14
src/platform/workflow/sharing/utils/normalizeTags.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Normalizes a tag to its slug form for the ComfyHub API.
|
||||
* Converts display names like "Text to Image" to "text-to-image".
|
||||
*/
|
||||
export function normalizeTag(tag: string): string {
|
||||
return tag.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and deduplicates an array of tags for API submission.
|
||||
*/
|
||||
export function normalizeTags(tags: string[]): string[] {
|
||||
return [...new Set(tags.map(normalizeTag).filter(Boolean))]
|
||||
}
|
||||
@@ -580,6 +580,9 @@ export class ComfyApp {
|
||||
// Get prompt from dropped PNG or json
|
||||
useEventListener(document, 'drop', async (event: DragEvent) => {
|
||||
try {
|
||||
// Skip if already handled (e.g. file drop onto publish dialog tiles)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
|
||||
@@ -123,12 +123,13 @@ export const useCustomerEventsService = () => {
|
||||
|
||||
function formatJsonValue(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
// Format numbers with commas and decimals if needed
|
||||
return value.toLocaleString()
|
||||
}
|
||||
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
// Format dates nicely
|
||||
return new Date(value).toLocaleString()
|
||||
if (typeof value === 'string') {
|
||||
const date = new Date(value)
|
||||
if (!Number.isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return d(date, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user