Compare commits

..

25 Commits

Author SHA1 Message Date
jaeone94
160c615bc4 fix(i18n): add g.inSubgraph locale key 2026-02-25 22:36:01 +09:00
jaeone94
eb61c0bb4d refactor: improve missing node error handling and add roadmap documentation
- App & WorkflowService: Document the temporary coexistence of the Missing Nodes Modal and Errors Tab, noting that the modal will be removed once Node Replacement is implemented.
- Error Handling: Collect `cnr_id` and `execution_id` when processing missing nodes to provide sufficient context for the Errors Tab.
- ExecutionErrorStore: Enforce strict `NodeExecutionId` typing in `applyNodeError`.
- Clean up obsolete comments and reorganize imports across error stores and app script.
2026-02-25 22:36:00 +09:00
jaeone94
4689581674 feat: enhance manager dialog with initial pack id support (#9169)
## Summary
Adds `initialPackId` support to the manager dialog so callers can
deep-link directly to a specific node pack — pre-filling the search
query, switching to packs search mode, and auto-selecting the matching
pack once results load.

## Changes
- **ManagerDialog.vue**: Added `initialPackId` prop; wires it into
`useRegistrySearch` (forces `packs` mode and pre-fills query) and uses
VueUse `until()` to auto-select the target pack and open the right panel
once `resultsWithKeys` is populated (one-shot, never re-triggers). Also
fixes a latent bug where the effective initial tab (resolving the
persisted tab) was not used when determining the initial search mode and
query — previously `initialTab` (the raw prop) was checked directly,
which would produce incorrect pre-fill when no tab prop was passed but a
Missing tab was persisted.
- **useManagerDialog.ts**: Threads `initialPackId` through `show()` into
the dialog props
- **useManagerState.ts**: Exposes `initialPackId` in `openManager`
options and passes it to `managerDialog.show()`; also removes a stale
fallback `show(ManagerTab.All)` call that was redundant for the
legacy-only error path

### Refactor: remove `executionIdUtil.ts` and distribute its functions
- **`getAncestorExecutionIds` / `getParentExecutionIds`** → moved to
`src/types/nodeIdentification.ts`: both are pure `NodeExecutionId`
string operations with no external dependencies, consistent with the
existing `parseNodeExecutionId` / `createNodeExecutionId` helpers
already in that file
- **`buildSubgraphExecutionPaths`** → moved to
`src/platform/workflow/validation/schemas/workflowSchema.ts`: operates
entirely on `ComfyNode[]` and `SubgraphDefinition` (both defined there),
and `isSubgraphDefinition` is already co-located in the same file
- Tests redistributed accordingly: ancestor/parent ID tests into
`nodeIdentification.test.ts`, `buildSubgraphExecutionPaths` tests into
`workflowSchema.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9169-feat-enhance-manager-dialog-with-initial-pack-id-support-3116d73d365081f7b6a3cbfb2f2755bf)
by [Unito](https://www.unito.io)
2026-02-25 22:23:53 +09:00
Benjamin Lu
e4b456bb2c fix: publish desktop-specific frontend release artifact (#9206)
## Summary
- add a desktop-specific frontend release artifact (`dist-desktop.zip`)
in release draft creation
- build `dist-desktop.zip` with `DISTRIBUTION=desktop`
- keep existing `dist.zip` behavior for core/PyPI consumers
- extend `scripts/zipdist.js` to support custom source and output paths

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9206-fix-publish-desktop-specific-frontend-release-artifact-3126d73d3650812495cdf6e9ad2ac280)
by [Unito](https://www.unito.io)
2026-02-25 03:35:41 -08:00
Alexander Brown
482ad401d4 fix: eradicate tailwind @apply usage in vue styles (#9146)
## Summary

Remove Tailwind `@apply` from Vue styles across `src/` and
`apps/desktop-ui/src/` to align with Tailwind v4 guidance, replacing
usages with template utilities or native CSS while preserving behavior.

## Changes

- **What**:
- Batch 1: migrated low-risk template/style utility bundles out of
`@apply`.
- Batch 2: converted PrimeVue/`:deep()` override `@apply` blocks to
native CSS declarations.
- Batch 3: converted `src/components/node/NodeHelpContent.vue` markdown
styling from `@apply` to native CSS/token-based declarations.
- Batch 4: converted final desktop pseudo-element `@apply` styles and
removed stale `@reference` directives no longer required.
- Verified `rg -n "^\s*@apply\b" src apps -g "*.vue"` has no real CSS
`@apply` directives remaining (only known template false-positive event
binding in `NodeSearchContent.vue`).

## Review Focus

- Visual parity in components that previously depended on `@apply` in
`:deep()` selectors and markdown content:
  - topbar tabs/popovers, dialogs, breadcrumb, terminal overrides
  - desktop install/dialog/update/maintenance surfaces
  - node help markdown rendering
- Confirm no regressions from removal of now-unneeded `@reference`
directives.

## Screenshots (if applicable)

- No new screenshots included in this PR.
- Screenshot Playwright suite was run with `--grep="@screenshot"` and
reports baseline diffs in this environment (164 passed, 39 failed, 3
skipped) plus a teardown `EPERM` restore error on local path
`C:\Users\DrJKL\ComfyUI\LTXV\user`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9146-fix-eradicate-tailwind-apply-usage-in-vue-styles-3116d73d3650813d8642e0bada13df32)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 21:23:52 -08:00
Benjamin Lu
9082f6bc3c fix: resolve desktop-ui build failure from icon path cwd mismatch (#9185)
## Summary
Desktop UI production builds were failing in distribution due to an icon
path being resolved from the wrong working directory.

## Problem
`@comfyorg/desktop-ui:build` runs with `cwd: apps/desktop-ui`, but
design-system CSS config includes:
`from-folder(comfy, './packages/design-system/src/icons')`

That relative path only exists from workspace root, so desktop builds
errored with:
`ENOENT: no such file or directory, scandir
'./packages/design-system/src/icons/'`

## Fix
Update the desktop build target to run Vite from workspace root by
removing the app-local `cwd` and using a root-relative config path:
- from: `vite build --config vite.config.mts` with `cwd:
apps/desktop-ui`
- to: `vite build --config apps/desktop-ui/vite.config.mts`

This keeps the icon path resolvable while preserving the same desktop
build config.

## Validation
- `pnpm nx run @comfyorg/desktop-ui:build --skip-nx-cache` 
- `pnpm build:desktop --skip-nx-cache` 

(Separate pre-existing issues remain in `@comfyorg/desktop-ui:typecheck`
and `@comfyorg/desktop-ui:lint`; unchanged by this PR.)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9185-fix-resolve-desktop-ui-build-failure-from-icon-path-cwd-mismatch-3126d73d3650813c94cae25a9240f9b7)
by [Unito](https://www.unito.io)
2026-02-24 20:48:41 -08:00
Comfy Org PR Bot
7c34a0e0f6 1.41.5 (#9182)
Patch version increment to 1.41.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9182-1-41-5-3126d73d3650811d84d7eaf9e384567a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-24 20:42:38 -08:00
Hunter
8c3738fb77 feat: add Free subscription tier support (#8864)
## Summary

Add frontend support for a Free subscription tier — login/signup page
restructuring, telemetry instrumentation, and tier-aware billing gating.

## Changes

- **What**: 
- Restructure login/signup pages: OAuth buttons promoted as primary
sign-in method, email login available via progressive disclosure
- Add Free tier badge on Google sign-up button with dynamic credit count
from remote config
- Add `FREE` subscription tier to type system (tier pricing, tier rank,
registry types)
  - Add `isFreeTier` computed to `useSubscription()`
- Disable credit top-up for Free tier users (dialogService,
purchaseCredits, popover CTA)
- Show subscription/upgrade dialog instead of top-up dialog when Free
tier user hits out-of-credits
- Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened`
with `free_tier_badge_shown`, track email toggle clicks

## Review Focus

- Tier gating logic: Free tier users should see "Upgrade" instead of
"Add Credits" and never reach the top-up flow
- Telemetry event design for Mixpanel funnel analysis
- Progressive disclosure UX on login/signup pages

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03)
by [Unito](https://www.unito.io)
2026-02-24 23:28:51 -05:00
Jin Yi
aee207f16c [bugfix] Fix workspace dialog pt override losing base styles (#9188)
## Summary
Workspace dialog `pt` overrides were spreading `workspaceDialogPt` then
replacing `pt.root`, which discarded other `pt` properties from the base
config. This fix removes the redundant overrides so all workspace
dialogs consistently use `workspaceDialogPt` as-is.

## Changes
- **What**: Remove incorrect `pt` spread-and-override pattern in 5
workspace dialog calls
- **Why**: The override replaced the entire `pt` object, losing styles
like `header: { class: 'p-0! hidden' }`

## Review Focus
- Verify that the removed `max-w-[400px]` / `max-w-[512px]` constraints
are either unnecessary or already handled by `workspaceDialogPt` or the
dialog components themselves

<img width="709" height="357" alt="스크린샷 2026-02-25 오후 12 16 08"
src="https://github.com/user-attachments/assets/5020664d-1a8c-478b-a16a-14f59bcf0dde"
/>
<img width="784" height="390" alt="스크린샷 2026-02-25 오후 12 16 03"
src="https://github.com/user-attachments/assets/041dc09d-5639-4880-a95d-a8a6e29e303e"
/>
<img width="551" height="392" alt="스크린샷 2026-02-25 오후 12 15 56"
src="https://github.com/user-attachments/assets/b9769a9d-c0fa-4400-b6d7-0358ba806eaa"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9188-bugfix-Fix-workspace-dialog-pt-override-losing-base-styles-3126d73d365081b8a73ffc681ccb52a6)
by [Unito](https://www.unito.io)
2026-02-25 12:26:09 +09:00
Jin Yi
164379bf4b [refactor] Redesign missing models dialog (#9014)
## Summary
Redesign the missing models warning dialog to match the MissingNodes
dialog pattern with header/content/footer separation, type badges, file
sizes, and context-sensitive actions.

## Changes
- **What**: Split `MissingModelsWarning.vue` into `MissingModelsHeader`,
`MissingModelsContent`, `MissingModelsFooter` components following the
established MissingNodes pattern. Added model type badges (VAE,
DIFFUSION, LORA, etc.), inline file sizes, total download size, custom
model warnings, and context-sensitive footer buttons (Download all /
Download available / Ok, got it). Extracted security validation into
shared `missingModelsUtils.ts`. Removed orphaned `FileDownload`,
`ElectronFileDownload`, `useDownload`, and `useCivitaiModel` files.
- **Breaking**: None

## Review Focus
- Badge styling and icon button variants for theme compatibility
- Security validation logic preserved correctly in extracted utility
- E2e test locator updates for the new dialog structure

<img width="641" height="478" alt="스크린샷 2026-02-20 오후 7 35 23"
src="https://github.com/user-attachments/assets/ded27dc7-04e6-431d-9b2e-a96ba61043a4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9014-refactor-Redesign-missing-models-dialog-30d6d73d365081809cb0c555c2c28034)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-25 10:51:18 +09:00
Dante
9108b7535a feat: support video file drag-drop and paste (#9154) 2026-02-25 07:59:26 +09:00
Alexander Brown
2ff14fadc2 fix: prevent infinite node resize loop in Vue mode (#9177)
## Summary

Fix infinite node resize loop in Vue mode where textarea widgets caused
nodes to grow ~33px per frame indefinitely.

## Changes

- **What**: Two feedback loops broken in the LiteGraph↔Vue layout sync:
1. `_arrangeWidgets()` in LiteGraph's draw loop was calling `setSize()`
every frame with its own computed widget height, which disagreed with
Vue's DOM-measured height. Guarded with `!LiteGraph.vueNodesMode`.
2. `useLayoutSync` was calling `setSize()` which triggers the size
setter → writes back to layoutStore with `source=Canvas` →
`handleLayoutChange` updates CSS vars → ResizeObserver fires → loop.
Changed to direct array assignment (matching the existing position sync
pattern).

## Review Focus

- The `_arrangeWidgets` guard: in Vue mode, the DOM/ResizeObserver is
the source of truth for node sizing, so LiteGraph should not grow nodes
via `setSize()`. Verify no Vue-mode features depend on this growth path.
- The `useLayoutSync` change: `liteNode.size[0] = ...` modifies `_size`
via the getter without triggering the setter, avoiding the Canvas-source
bounce. `onResize` is still called. Verify no downstream code relies on
the setter side effects when syncing from layout store.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9177-fix-prevent-infinite-node-resize-loop-in-Vue-mode-3116d73d365081e4ad88f1cfad51df18)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-24 21:12:16 +00:00
Comfy Org PR Bot
87341f2c6e 1.41.4 (#9139)
Patch version increment to 1.41.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9139-1-41-4-3116d73d3650813a9061e5695a844233)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-24 03:40:20 -08:00
Dante
02a38110cd feat: audio drag-drop and paste support (#9152) 2026-02-24 18:59:57 +09:00
jaeone94
09989b7aff [refactor] Extract manager composables and execution utils (#9163)
## Summary
Extracts inline logic from manager components into dedicated composables
and utilities, and adds a cyclic subgraph fix.

## Changes
- **`usePackInstall`**: New composable extracted from
`PackInstallButton.vue` — handles conflict detection, payload
construction, and `Promise.allSettled`-based batch installation
- **`useApplyChanges`**: New shared composable extracted from
`ManagerProgressToast.vue` — manages ComfyUI restart flow with reconnect
timeout and post-reconnect refresh
- **`executionIdUtil`**: New utility (`getAncestorExecutionIds`,
`getParentExecutionIds`, `buildSubgraphExecutionPaths`) with unit tests;
fixes infinite recursion on cyclic subgraph definitions

## Review Focus
- `useApplyChanges` reconnect timeout (2 min) and setting restore logic
- `buildSubgraphExecutionPaths` visited-set guard for cyclic subgraph
defs

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9163-refactor-Extract-manager-composables-and-execution-utils-3116d73d365081f293d3d5484775ad48)
by [Unito](https://www.unito.io)
2026-02-24 01:39:44 -08:00
Hunter
0f455c73bb fix: sync DOM widget values to widgetValueStore on registration (#9166)
## Summary

Override `setNodeId` in `BaseDOMWidgetImpl` to sync the DOM-resolved
value into the widget value store, fixing empty system prompts in Vue
nodes (Nodes 2.0).

## Changes

- **What**: DOM widgets (e.g. textarea for Gemini system_prompt) resolve
their value through `options.getValue()` / DOM elements, not
`_state.value`. When `BaseWidget.setNodeId` registers with the store, it
spreads `_state.value` which is `undefined` for DOM widgets. The
override captures the DOM-resolved value before registration and syncs
it into the store afterward — keeping the fix in the DOM widget layer
where the mismatch originates, leaving `BaseWidget` unchanged.

## Review Focus

- Whether capturing `this.value` before `super.setNodeId()` and writing
it after is the right sequencing
- Whether this correctly handles all DOM widget subtypes
(`DOMWidgetImpl`, `ComponentWidgetImpl`)

Supersedes #9164

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9166-fix-sync-DOM-widget-values-to-widgetValueStore-on-registration-3116d73d3650816f8cece866a9272baa)
by [Unito](https://www.unito.io)
2026-02-24 01:05:44 -08:00
Christian Byrne
a94574d379 fix: open image in new tab on cloud fetches as blob to avoid GCS auto-download (#9122)
## Summary

Fix "Open Image" on cloud opening a new tab that auto-downloads the
asset instead of displaying it inline.

## Changes

- **What**: Add `openFileInNewTab()` to `downloadUtil.ts` that fetches
cross-origin URLs as blobs before opening in a new tab, avoiding GCS
`Content-Disposition: attachment` redirects. Opens the blank tab
synchronously to preserve user-gesture activation (avoiding popup
blockers), then navigates to a blob URL once the fetch completes. Blob
URLs are revoked after 60s or immediately if the tab was closed. Update
both call sites (`useImageMenuOptions` and `litegraphService`) to use
the new function.

## Review Focus

- The synchronous `window.open('', '_blank')` before the async fetch is
intentional to preserve user-gesture context and avoid popup blockers.
- Blob URL revocation strategy: 60s timeout for successful opens,
immediate revoke if tab was closed, tab closed on fetch failure.
- Shared `fetchAsBlob()` helper is also used by the existing
`downloadViaBlobFetch`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9122-fix-open-image-in-new-tab-on-cloud-fetches-as-blob-to-avoid-GCS-auto-download-3106d73d365081a3bfa6eb7d77fde99f)
by [Unito](https://www.unito.io)
2026-02-23 21:28:16 -08:00
Johnpaul Chiwetelu
78fe639540 feat: periodically re-poll queue progress state (#9136)
## Summary
- Add `useQueuePolling` composable that polls `queueStore.update()`
every 5s while jobs are active
- Calls `update()` immediately on creation so the UI is current after
page reload
- Uses `useIntervalFn` + `watch` pattern (same as `assetDownloadStore`)
to pause/resume based on `activeJobsCount`

## Related Issue
- Related to #8136

## QA
- Queue a prompt, reload page mid-execution, verify queue UI updates
every ~5s
- Verify polling stops when queue empties
- Verify polling resumes when new jobs are queued

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9136-feat-periodically-re-poll-queue-progress-state-3106d73d36508119a32fc5b9c8eda21c)
by [Unito](https://www.unito.io)
2026-02-23 21:27:24 -08:00
Terry Jia
e333ad459e feat: add CurveEditor component (#8860)
## Summary
Prerequisite for upcoming native color correction nodes (ColorCurves).

Reusable curve editor with monotone cubic Hermite interpolation,
drag-to-add/move/delete control points, and SVG-based rendering.
Includes CurvePoint type, LUT generation utility, and useCurveEditor
composable for interaction logic.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/948352c7-bdf2-40f9-a8f0-35bc2b2f3202

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8860-feat-add-CurveEditor-component-and-d3-shape-dependency-3076d73d3650817f8421f98e349569d0)
by [Unito](https://www.unito.io)
2026-02-23 21:19:06 -08:00
Johnpaul Chiwetelu
aab09b5f99 feat: allow custom event names in ComfyApi.addEventListener (#9140)
## Summary
- Add string fallback overloads to `addEventListener` and
`removeEventListener` on `ComfyApi`
- Extensions can now listen for custom event names without TypeScript
rejecting unknown event names
- Known events still get full type safety via the generic overload;
unknown strings fall through to `CustomEvent`

## Related Issue
- Fixes #2088

## QA
- Verify existing typed event listeners (e.g.
`api.addEventListener('status', ...)`) still infer correct types
- Verify custom event names (e.g.
`api.addEventListener('my-custom-event', ...)`) compile without errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9140-feat-allow-custom-event-names-in-ComfyApi-addEventListener-3116d73d36508128aad3fab98c34fac3)
by [Unito](https://www.unito.io)
2026-02-23 21:17:35 -08:00
Johnpaul Chiwetelu
1f0ca18737 fix: refresh image previews on media upload nodes when refreshing node definitions (#9141)
## Summary
- When pressing `R` to refresh node definitions, image previews on
LoadImage/LoadVideo nodes now update to reflect external file changes
- Re-triggers the combo widget callback to regenerate preview URLs with
a fresh cache-busting `&rand=` parameter
- Extracts `isMediaUploadComboInput` from `uploadImage.ts` to
`nodeDefSchema.ts` as a shared utility

- Fixes #2082



https://github.com/user-attachments/assets/d18d69ae-6ecd-448d-8d7c-76b2c49fdea5



## Test plan
- [ ] Open a workflow with a LoadImage node and select an image
- [ ] Edit and save the image externally (e.g. in an image editor)
- [ ] Press `R` to refresh node definitions
- [ ] Verify the preview updates to show the edited image
2026-02-23 20:57:24 -08:00
Terry Jia
3b5649232d feat: add batch image navigation to ImageCompare node (#9151)
## Summary

Add batch image navigation to the ImageCompare node so users can browse
all images in a batch instead of only seeing the first one.
## Changes
The backend already returns all batch images in a_images/b_images
arrays, but the frontend only used index [0]. Now all images are mapped
to URLs and a navigation bar with prev/next controls appears above the
comparison slider when either side has more than one image. A/B sides
navigate independently. Extracted a reusable BatchNavigation component
for the index selector UI.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9098

## Screenshots (if applicable)


https://github.com/user-attachments/assets/a801cc96-9182-4b0d-a342-4e6107290f47

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9151-feat-add-batch-image-navigation-to-ImageCompare-node-3116d73d365081498be6d401773619a3)
by [Unito](https://www.unito.io)
2026-02-23 23:56:46 -05:00
Johnpaul Chiwetelu
724827d8cc refactor: replace withDefaults with Vue 3.5 props destructuring (#9150)
## Summary
- Replace all `withDefaults(defineProps<...>())` with Vue 3.5 reactive
props destructuring across 14 components
- Update `props.xxx` references to use destructured variables directly
in script and template

- Fixes #2334

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9150-refactor-replace-withDefaults-with-Vue-3-5-props-destructuring-3116d73d365081e7a721db3369600671)
by [Unito](https://www.unito.io)
2026-02-23 20:30:44 -08:00
Jin Yi
be70f6c1e6 [refactor] Merge sort button into view settings popover (#9143) 2026-02-24 13:18:07 +09:00
Christian Byrne
514425b560 fix: use getAuthHeader for API key auth in subscription/billing (#9142)
## Summary

Fix "User not authenticated" errors when API key users
(desktop/portable) trigger subscription status checks or billing
operations.

## Changes

- **What**: Replace `getFirebaseAuthHeader()` with `getAuthHeader()` in
subscription and billing call sites (`fetchSubscriptionStatus`,
`initiateSubscriptionCheckout`, `fetchBalance`, `addCredits`,
`accessBillingPortal`, `performSubscriptionCheckout`). `getAuthHeader()`
supports the full auth fallback chain (workspace token → Firebase token
→ API key), whereas `getFirebaseAuthHeader()` returns null for API key
users since they bypass Firebase entirely. Also add an `isCloud` guard
to the subscription status watcher so non-cloud environments skip
subscription checks.

## Review Focus

- The `isCloud` guard on the watcher ensures local/desktop users never
hit the subscription endpoint. This was the originally intended design
per code owner confirmation.
- `getAuthHeader()` already exists in `firebaseAuthStore` with proper
fallback logic — no new auth code was added.

Fixes
https://www.notion.so/comfy-org/Bug-Subscription-status-check-occurring-in-non-cloud-environments-causing-authentication-errors-3116d73d365081738b21db157e88a9ed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9142-fix-use-getAuthHeader-for-API-key-auth-in-subscription-billing-3116d73d3650817fa345deaddc8c3fcd)
by [Unito](https://www.unito.io)
2026-02-23 18:49:32 -08:00
253 changed files with 6509 additions and 10209 deletions

View File

@@ -53,7 +53,13 @@ jobs:
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
pnpm build
# Desktop-specific release artifact with desktop distribution flags.
DISTRIBUTION=desktop pnpm build
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
@@ -62,6 +68,7 @@ jobs:
path: |
dist/
dist.zip
dist-desktop.zip
draft_release:
needs: build
@@ -79,6 +86,7 @@ jobs:
with:
files: |
dist.zip
dist-desktop.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: >-

View File

@@ -40,12 +40,12 @@
"block-no-empty": true,
"no-descending-specificity": null,
"no-duplicate-at-import-rules": true,
"at-rule-disallowed-list": ["apply"],
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"layer",
"config",
"theme",

View File

@@ -61,8 +61,7 @@
"^build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite build --config vite.config.mts"
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"

View File

@@ -4,3 +4,39 @@
position: absolute;
inset: 0;
}
.p-button-secondary {
border: none;
background-color: var(--color-neutral-600);
color: var(--color-white);
}
.p-button-secondary:hover {
background-color: var(--color-neutral-550);
}
.p-button-secondary:active {
background-color: var(--color-neutral-500);
}
.p-button-danger {
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
}
.task-div .p-card {
transition: opacity var(--default-transition-duration);
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
}
.task-div .p-card:hover {
opacity: 1;
}

View File

@@ -101,13 +101,15 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
* required to style those generated nodes.
*/
:deep(.p-terminal) .xterm {
@apply overflow-hidden;
overflow: hidden;
}
:deep(.p-terminal) .xterm-screen {
@apply bg-neutral-900 overflow-hidden;
overflow: hidden;
background-color: var(--color-neutral-900);
}
</style>

View File

@@ -7,7 +7,7 @@
option-value="value"
:disabled="isSwitching"
:pt="dropdownPt"
:size="props.size"
:size="size"
class="language-selector"
@change="onLocaleChange"
>
@@ -36,16 +36,10 @@ import { i18n, loadLocale, st } from '@/i18n'
type VariantKey = 'dark' | 'light'
type SizeKey = 'small' | 'large'
const props = withDefaults(
defineProps<{
variant?: VariantKey
size?: SizeKey
}>(),
{
variant: 'dark',
size: 'small'
}
)
const { variant = 'dark', size = 'small' } = defineProps<{
variant?: VariantKey
size?: SizeKey
}>()
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
@@ -104,10 +98,8 @@ const VARIANT_PRESETS = {
const selectedLocale = ref<string>(i18n.global.locale.value)
const isSwitching = ref(false)
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
const variantPreset = computed(
() => VARIANT_PRESETS[props.variant as VariantKey]
)
const sizePreset = computed(() => SIZE_PRESETS[size])
const variantPreset = computed(() => VARIANT_PRESETS[variant])
const dropdownPt = computed(() => ({
root: {
@@ -195,13 +187,17 @@ async function onLocaleChange(event: SelectChangeEvent) {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
@apply transition-colors;
transition-property: color, background-color, border-color;
transition-duration: var(--default-transition-duration);
}
:deep(.p-dropdown) {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
&:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-neutral-900),
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
}
}
</style>

View File

@@ -269,26 +269,43 @@ const onFocus = async () => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
padding-inline: calc(var(--spacing) * 12);
.p-accordionpanel {
@apply border-0 bg-transparent;
border: 0;
background-color: transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
margin-top: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-700) 50%,
transparent
);
}
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.p-accordionheader-toggle-icon {
@@ -299,11 +316,24 @@ const onFocus = async () => {
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: var(--radius-xl);
border-bottom-left-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
background-color: transparent;
padding-top: calc(var(--spacing) * 3);
padding-right: calc(var(--spacing) * 5);
padding-bottom: calc(var(--spacing) * 5);
padding-left: calc(var(--spacing) * 5);
}
/* Override default chevron icons to use up/down */

View File

@@ -1,11 +1,20 @@
<template>
<div
class="task-div relative grid min-h-52 max-w-48"
:class="{ 'opacity-75': isLoading }"
:class="
cn(
'task-div group/task-card relative grid min-h-52 max-w-48',
isLoading && 'opacity-75'
)
"
>
<Card
class="relative h-full max-w-48 overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
:class="
cn(
'relative h-full max-w-48 overflow-hidden',
runner.state !== 'error' && 'opacity-65'
)
"
:pt="cardPt"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
@@ -43,7 +52,7 @@
<i
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
/>
</div>
</template>
@@ -55,6 +64,7 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@/utils/tailwindUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
@@ -83,51 +93,9 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
const cardPt = {
header: { class: 'z-0' },
body: { class: 'z-[1] grow justify-between' }
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
{{ t(`desktopDialogs.${id}.title`, title) }}
{{ $t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">
{{ t(`desktopDialogs.${id}.message`, message) }}
{{ $t(`desktopDialogs.${id}.message`, message) }}
</p>
<div class="flex w-full gap-2">
<Button
@@ -12,7 +12,7 @@
:key="button.label"
class="rounded-lg first:mr-auto"
:label="
t(
$t(
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
button.label
)
@@ -31,7 +31,6 @@ import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
@@ -41,31 +40,3 @@ const handleButtonClick = async (button: DialogAction) => {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>
<style scoped>
@reference '../assets/css/style.css';
.p-button-secondary {
@apply text-white border-none bg-neutral-600;
}
.p-button-secondary:hover {
@apply bg-neutral-550;
}
.p-button-secondary:active {
@apply bg-neutral-500;
}
.p-button-danger {
@apply bg-coral-red-600;
}
.p-button-danger:hover {
@apply bg-coral-red-500;
}
.p-button-danger:active {
@apply bg-coral-red-400;
}
</style>

View File

@@ -6,11 +6,11 @@
<div class="relative m-8 text-center">
<!-- Header -->
<h1 class="download-bg pi-download text-4xl font-bold">
{{ t('desktopUpdate.title') }}
{{ $t('desktopUpdate.title') }}
</h1>
<div class="m-8">
<span>{{ t('desktopUpdate.description') }}</span>
<span>{{ $t('desktopUpdate.description') }}</span>
</div>
<ProgressSpinner class="m-8 w-48 h-48" />
@@ -19,7 +19,7 @@
<Button
style="transform: translateX(-50%)"
class="fixed bottom-0 left-1/2 my-8"
:label="t('maintenance.consoleLogs')"
:label="$t('maintenance.consoleLogs')"
icon="pi pi-desktop"
icon-pos="left"
severity="secondary"
@@ -28,8 +28,8 @@
<TerminalOutputDrawer
v-model="terminalVisible"
:header="t('g.terminal')"
:default-message="t('desktopUpdate.terminalDefaultMessage')"
:header="$t('g.terminal')"
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
/>
</div>
</div>
@@ -44,7 +44,6 @@ import Toast from 'primevue/toast'
import { onUnmounted, ref } from 'vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
@@ -61,10 +60,10 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
.download-bg::before {
@apply m-0 absolute text-muted;
position: absolute;
margin: 0;
color: var(--muted-foreground);
font-family: 'primeicons', sans-serif;
top: -2rem;
right: 2rem;

View File

@@ -183,33 +183,37 @@ onMounted(async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-steppanel) {
@apply mt-8 flex justify-center bg-transparent;
margin-top: calc(var(--spacing) * 8);
display: flex;
justify-content: center;
background-color: transparent;
}
/* Remove default padding/margin from StepPanels to make scrollbar flush */
:deep(.p-steppanels) {
@apply p-0 m-0;
margin: 0;
padding: 0;
}
/* Ensure StepPanel content container has no top/bottom padding */
:deep(.p-steppanel-content) {
@apply p-0;
padding: 0;
}
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
:deep(.p-steppanels::-webkit-scrollbar) {
@apply w-4;
width: calc(var(--spacing) * 4);
}
:deep(.p-steppanels::-webkit-scrollbar-track) {
@apply bg-transparent;
background-color: transparent;
}
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
border: 4px solid transparent;
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
background-clip: content-box;
}
</style>

View File

@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
@@ -129,6 +129,7 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
const electron = electronAPI()
const toast = useToast()
const { t } = useI18n()
const taskStore = useMaintenanceTaskStore()
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
@@ -220,14 +221,14 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-tag) {
--p-tag-gap: 0.375rem;
}
.backspan::before {
@apply m-0 absolute text-muted;
position: absolute;
margin: 0;
color: var(--muted-foreground);
font-family: 'primeicons', sans-serif;
top: -2rem;
right: -2rem;

View File

@@ -1,6 +1,6 @@
<template>
<BaseViewTemplate>
<div class="sad-container">
<div class="sad-container grid items-center justify-evenly">
<!-- Right side image -->
<img
class="sad-girl"
@@ -79,10 +79,7 @@ const continueToInstall = async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
.sad-container {
@apply grid items-center justify-evenly;
grid-template-columns: 25rem 1fr;
& > * {

View File

@@ -232,8 +232,6 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../assets/css/style.css';
/* Hide the xterm scrollbar completely */
:deep(.p-terminal) .xterm-viewport {
overflow: hidden !important;

View File

@@ -44,6 +44,12 @@ export const TestIds = {
node: {
titleInput: 'node-title-input'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
},
widgets: {
decrement: 'decrement',
increment: 'increment',
@@ -74,6 +80,7 @@ export type TestIdValue =
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

@@ -104,15 +104,13 @@ test.describe('Missing models warning', () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
@@ -123,15 +121,13 @@ test.describe('Missing models warning', () => {
'missing/missing_models_from_node_properties'
)
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
@@ -172,8 +168,10 @@ test.describe('Missing models warning', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
@@ -186,8 +184,10 @@ test.describe('Missing models warning', () => {
)
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
// Flaky test after parallelization
@@ -199,13 +199,15 @@ test.describe('Missing models warning', () => {
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = comfyPage.page.getByText('Download')
await expect(downloadButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadButton.click()
await downloadAllButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
@@ -229,13 +231,14 @@ test.describe('Missing models warning', () => {
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await closeButton.click()
await checkbox.click()
await changeSettingPromise
await closeButton.click()
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -1,6 +1,8 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const test = comfyPageFixture
@@ -10,6 +12,17 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
@@ -132,28 +145,24 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should be visible
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
const colorPickerButton = getColorPickerButton(comfyPage)
await expect(colorPickerButton).toBeVisible()
// Click color picker button
await colorPickerButton.click()
// Color picker dropdown should be visible
const colorPickerDropdown = comfyPage.page.locator(
'.color-picker-container'
)
await expect(colorPickerDropdown).toBeVisible()
const colorPickerGroup = getColorPickerGroup(comfyPage)
await expect(colorPickerGroup).toBeVisible()
// Select a color (e.g., blue)
const blueColorOption = colorPickerDropdown.locator(
'i[data-testid="blue"]'
const blueColorOption = colorPickerGroup.getByTestId(
TestIds.selectionToolbox.colorBlue
)
await blueColorOption.click()
// Dropdown should close after selection
await expect(colorPickerDropdown).not.toBeVisible()
await expect(colorPickerGroup).not.toBeVisible()
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
@@ -172,22 +181,21 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
'CLIP Text Encode (Prompt)'
])
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
// Initially should show default color
await expect(colorPickerButton).not.toHaveAttribute('color')
// Click color picker and select a color
await colorPickerButton.click()
const redColorOption = comfyPage.page.locator(
'.color-picker-container i[data-testid="red"]'
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
TestIds.selectionToolbox.colorRed
)
await redColorOption.click()
// Button should now show the selected color
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
})
test('color picker shows mixed state for differently colored selections', async ({
@@ -195,17 +203,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select first node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Select second node and color it differently
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="red"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorRed)
.click()
// Select both nodes
@@ -215,9 +223,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
])
// Color picker should show null/mixed state
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
const colorPickerButton = getColorPickerButton(comfyPage)
await expect(colorPickerButton).not.toHaveAttribute('color')
})
@@ -226,9 +232,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// First color a node
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
// Clear selection
@@ -238,10 +244,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should show the correct color
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
})
test('colorization via color picker can be undone', async ({
@@ -249,9 +253,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select a node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
// Undo the colorization

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { TestIds } from '../../../fixtures/selectors'
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +20,16 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
const colorPickerButton = comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerButton
)
await colorPickerButton.click()
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
await colorPickerGroup
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -39,6 +39,10 @@ Prefer Vue native options when available:
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
- Exception: when third-party libraries render runtime DOM outside Vue templates
(for example xterm internals inside PrimeVue terminal wrappers), scoped
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
exception is required.
## Best Practices

View File

@@ -4,6 +4,11 @@ export default {
'tests-ui/**': () =>
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.{css,vue}': (stagedFiles: string[]) => {
const joinedPaths = toJoinedRelativePaths(stagedFiles)
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
},
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
@@ -22,12 +27,17 @@ export default {
}
function formatAndEslint(fileNames: string[]) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}
function toJoinedRelativePaths(fileNames: string[]) {
const relativePaths = fileNames.map((f) =>
path.relative(process.cwd(), f).replace(/\\/g, '/')
)
return relativePaths.map((p) => `"${p}"`).join(' ')
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.0",
"version": "1.41.5",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -29,10 +29,10 @@
"knip": "knip --cache",
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "oxlint src --type-aware && eslint src",
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",

View File

@@ -156,6 +156,7 @@
:root {
--fg-color: #000;
--bg-color: #fff;
--default-transition-duration: 0.1s;
--comfy-menu-bg: #353535;
--comfy-menu-secondary-bg: #292929;
--comfy-topbar-height: 2.5rem;

View File

@@ -3952,7 +3952,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/**
* @description The subscription billing duration
* @enum {string}

View File

@@ -1,9 +1,14 @@
import zipdir from 'zip-dir'
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
const sourceDir = process.argv[2] || './dist'
const outputPath = process.argv[3] || './dist.zip'
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
if (err) {
console.error('Error zipping "dist" directory:', err)
console.error(`Error zipping "${sourceDir}" directory:`, err)
} else {
console.log('Successfully zipped "dist" directory.')
process.stdout.write(
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
)
}
})

View File

@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadFile,
extractFilenameFromContentDisposition
extractFilenameFromContentDisposition,
openFileInNewTab
} from '@/base/common/downloadUtil'
let mockIsCloud = false
const { mockIsCloud } = vi.hoisted(() => ({
mockIsCloud: { value: false }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud
return mockIsCloud.value
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
}))
// Global stubs
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
mockIsCloud = false
mockIsCloud.value = false
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
})
it('streams downloads via blob when running in cloud', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
})
it('logs an error when cloud fetch fails', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve()
await Promise.resolve() // let fetchAsBlob throw
await Promise.resolve() // let .catch handler run
expect(consoleSpy).toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
})
})
describe('openFileInNewTab', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.useRealTimers()
})
it('opens URL directly when not in cloud mode', async () => {
mockIsCloud.value = false
const testUrl = 'https://example.com/image.png'
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
expect(fetchMock).not.toHaveBeenCalled()
})
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
expect(fetchMock).toHaveBeenCalledWith(testUrl)
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(mockTab.location.href).toBe('blob:mock-url')
})
it('revokes blob URL after timeout in cloud mode', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
vi.advanceTimersByTime(60_000)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
})
it('closes blank tab and logs error when cloud fetch fails', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(mockTab.close).toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('revokes blob URL immediately if tab was closed by user', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
expect(mockTab.location.href).toBe('')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()

View File

@@ -1,7 +1,9 @@
/**
* Utility functions for downloading files
*/
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
return null
}
const downloadViaBlobFetch = async (
/**
* Fetch a URL and return its body as a Blob.
* Shared by download and open-in-new-tab cloud paths.
*/
async function fetchAsBlob(url: string): Promise<Response> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`)
}
return response
}
async function downloadViaBlobFetch(
href: string,
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
): Promise<void> {
const response = await fetchAsBlob(href)
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
const blob = await response.blob()
downloadBlob(headerFilename ?? fallbackFilename, blob)
}
/**
* Open a file URL in a new browser tab.
* On cloud, fetches the resource as a blob first to avoid GCS redirects
* that would trigger an auto-download instead of displaying the file.
*
* Opens the tab synchronously to preserve the user-gesture context
* (browsers block window.open after an await), then navigates it to
* the blob URL once the fetch completes.
*/
export async function openFileInNewTab(url: string): Promise<void> {
if (!isCloud) {
window.open(url, '_blank')
return
}
// Open immediately to preserve user-gesture activation.
const tab = window.open('', '_blank')
try {
const response = await fetchAsBlob(url)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
if (tab && !tab.closed) {
tab.location.href = blobUrl
// Revoke after the tab has had time to load the blob.
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
} else {
URL.revokeObjectURL(blobUrl)
}
} catch (error) {
tab?.close()
console.error('Failed to open image:', error)
useToastStore().addAlert(
t('toastMessages.errorOpenImage', {
error: error instanceof Error ? error.message : String(error)
})
)
}
}

View File

@@ -103,13 +103,12 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
@apply overflow-hidden;
overflow: hidden;
}
:deep(.p-terminal) .xterm-screen {
@apply bg-neutral-900 overflow-hidden;
overflow: hidden;
background-color: var(--color-neutral-900);
}
</style>

View File

@@ -195,8 +195,6 @@ onUpdated(() => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
@@ -205,7 +203,7 @@ onUpdated(() => {
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
@apply overflow-hidden;
overflow: hidden;
}
:deep(.p-breadcrumb) {
@@ -214,7 +212,10 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden h-8;
display: flex;
align-items: center;
overflow: hidden;
height: calc(var(--spacing) * 8);
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
border: 1px solid transparent;
background-color: transparent;
@@ -236,7 +237,7 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:hover) {
@apply rounded-lg;
border-radius: var(--radius-lg);
border-color: var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -270,18 +271,16 @@ onUpdated(() => {
</style>
<style>
@reference '../../assets/css/style.css';
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
@apply hidden;
display: none;
}
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply flex;
display: flex;
}
}
</style>

View File

@@ -78,9 +78,7 @@ interface Props {
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { item, isActive = false } = defineProps<Props>()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -103,7 +101,7 @@ const rename = async (
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
@@ -127,13 +125,13 @@ const rename = async (
}
}
const isRoot = props.item.key === 'root'
const isRoot = item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return props.item.label
return item.label
})
const startRename = async () => {
@@ -145,7 +143,7 @@ const startRename = async () => {
}
isEditing.value = true
itemLabel.value = props.item.label as string
itemLabel.value = item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
}
if (event.detail === 1) {
if (props.isActive) {
if (isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
item.command?.({ item: item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
} else if (isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
@@ -180,7 +178,7 @@ const handleClick = (event: MouseEvent) => {
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
await rename(itemLabel.value, item.label as string)
}
isEditing.value = false
@@ -188,19 +186,19 @@ const inputBlur = async (doRename: boolean) => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
user-select: none;
}
.p-breadcrumb-item-link {
@apply overflow-hidden;
overflow: hidden;
}
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-breadcrumb-item {

View File

@@ -1,148 +0,0 @@
<!-- A Electron-backed download button with a label, size hint and progress bar -->
<template>
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2">
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
<div class="file-action flex flex-row items-center gap-2">
<Button
v-if="status === null || status === 'error'"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!props.url"
variant="secondary"
size="sm"
@click="copyURL"
>
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
<div
v-if="status === 'in_progress' || status === 'paused'"
class="flex flex-row items-center gap-2"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="downloadProgress"
:show-value="downloadProgress > 10"
/>
<Button
v-if="status === 'in_progress'"
v-tooltip.top="t('electronFileDownload.pause')"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
</Button>
<Button
v-if="status === 'paused'"
v-tooltip.top="t('electronFileDownload.resume')"
class="file-action-button"
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!props.error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
</Button>
<Button
v-tooltip.top="t('electronFileDownload.cancel')"
class="file-action-button"
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!props.error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const { t } = useI18n()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
downloadProgress.value = Number((download.progress * 100).toFixed(1))
// @ts-expect-error fixme ts strict error
status.value = download.status
}
})
const triggerDownload = async () => {
await electronDownloadStore.start({
url: props.url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const copyURL = async () => {
await copyToClipboard(props.url)
}
</script>

View File

@@ -1,69 +0,0 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="hint">{{ label }}</span>
</div>
<Message
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!props.error"
:title="props.url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -117,20 +117,18 @@ function getFormComponent(item: FormItem): Component {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.form-input :deep(.input-slider) .p-inputnumber input,
.form-input :deep(.input-slider) .slider-part {
@apply w-20;
width: 5rem;
}
.form-input :deep(.input-knob) .p-inputnumber input,
.form-input :deep(.input-knob) .knob-part {
@apply w-32;
width: 8rem;
}
.form-input :deep(.p-inputtext),
.form-input :deep(.p-select) {
@apply w-44;
width: 11rem;
}
</style>

View File

@@ -133,8 +133,6 @@ const wrapperStyle = computed(() => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}

View File

@@ -1,6 +1,6 @@
<template>
<Chip removable @remove="$emit('remove', $event)">
<Badge size="small" :class="badgeClass">
<Chip removable @remove="emit('remove', $event)">
<Badge size="small" :class="semanticBadgeClass">
{{ badge }}
</Badge>
{{ text }}
@@ -10,6 +10,7 @@
<script setup lang="ts">
import Badge from 'primevue/badge'
import Chip from 'primevue/chip'
import { computed } from 'vue'
export interface SearchFilter {
text: string
@@ -18,26 +19,19 @@ export interface SearchFilter {
id: string | number
}
defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
const semanticClassMap: Record<string, string> = {
'i-badge': 'bg-green-500 text-white',
'o-badge': 'bg-red-500 text-white',
'c-badge': 'bg-blue-500 text-white',
's-badge': 'bg-yellow-500'
}
const props = defineProps<Omit<SearchFilter, 'id'>>()
const emit = defineEmits<{
(e: 'remove', event: Event): void
}>()
const semanticBadgeClass = computed(() => {
return semanticClassMap[props.badgeClass] ?? props.badgeClass
})
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.i-badge) {
@apply bg-green-500 text-white;
}
:deep(.o-badge) {
@apply bg-red-500 text-white;
}
:deep(.c-badge) {
@apply bg-blue-500 text-white;
}
:deep(.s-badge) {
@apply bg-yellow-500;
}
</style>

View File

@@ -0,0 +1,113 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'
function mountEditor(points: CurvePoint[], extraProps = {}) {
return mount(CurveEditor, {
props: { modelValue: points, ...extraProps }
})
}
function getCurvePath(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-testid="curve-path"]')
}
describe('CurveEditor', () => {
it('renders SVG with curve path', () => {
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('svg').exists()).toBe(true)
const curvePath = getCurvePath(wrapper)
expect(curvePath.exists()).toBe(true)
expect(curvePath.attributes('d')).toBeTruthy()
})
it('renders a circle for each control point', () => {
const wrapper = mountEditor([
[0, 0],
[0.5, 0.7],
[1, 1]
])
expect(wrapper.findAll('circle')).toHaveLength(3)
})
it('renders histogram path when provided', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const wrapper = mountEditor(
[
[0, 0],
[1, 1]
],
{ histogram }
)
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
expect(histogramPath.exists()).toBe(true)
expect(histogramPath.attributes('d')).toContain('M0,1')
})
it('does not render histogram path when not provided', () => {
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
})
it('returns empty path with fewer than 2 points', () => {
const wrapper = mountEditor([[0.5, 0.5]])
expect(getCurvePath(wrapper).attributes('d')).toBe('')
})
it('generates path starting with M and containing L segments', () => {
const wrapper = mountEditor([
[0, 0],
[0.5, 0.8],
[1, 1]
])
const d = getCurvePath(wrapper).attributes('d')!
expect(d).toMatch(/^M/)
expect(d).toContain('L')
})
it('curve path only spans the x-range of control points', () => {
const wrapper = mountEditor([
[0.2, 0.3],
[0.8, 0.9]
])
const d = getCurvePath(wrapper).attributes('d')!
const xValues = d
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[0]))
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
})
it('deletes a point on right-click but keeps minimum 2', async () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.5],
[1, 1]
]
const wrapper = mountEditor(points)
expect(wrapper.findAll('circle')).toHaveLength(3)
await wrapper.findAll('circle')[1].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
await wrapper.findAll('circle')[0].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
})
})

View File

@@ -0,0 +1,103 @@
<template>
<svg
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
@contextmenu.prevent.stop
>
<line
v-for="v in [0.25, 0.5, 0.75]"
:key="'h' + v"
:x1="0"
:y1="v"
:x2="1"
:y2="v"
stroke="currentColor"
stroke-opacity="0.1"
stroke-width="0.003"
/>
<line
v-for="v in [0.25, 0.5, 0.75]"
:key="'v' + v"
:x1="v"
:y1="0"
:x2="v"
:y2="1"
stroke="currentColor"
stroke-opacity="0.1"
stroke-width="0.003"
/>
<line
x1="0"
y1="1"
x2="1"
y2="0"
stroke="currentColor"
stroke-opacity="0.15"
stroke-width="0.003"
/>
<path
v-if="histogramPath"
data-testid="histogram-path"
:d="histogramPath"
:fill="curveColor"
fill-opacity="0.15"
stroke="none"
/>
<path
data-testid="curve-path"
:d="curvePath"
fill="none"
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
/>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</svg>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import { histogramToPath } from './curveUtils'
const { curveColor = 'white', histogram } = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
}>()
const modelValue = defineModel<CurvePoint[]>({
required: true
})
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -0,0 +1,16 @@
<template>
<CurveEditor v-model="modelValue" />
</template>
<script setup lang="ts">
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
})
</script>

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import {
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
} from './curveUtils'
describe('createMonotoneInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createMonotoneInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('produces monotone output for monotone input', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.2],
[0.5, 0.5],
[0.75, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
let prev = -Infinity
for (let x = 0; x <= 1; x += 0.01) {
const y = interpolate(x)
expect(y).toBeGreaterThanOrEqual(prev)
prev = y
}
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
expect(lut).toBeInstanceOf(Uint8Array)
expect(lut.length).toBe(256)
})
it('produces identity LUT for diagonal curve', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeCloseTo(i, 0)
}
})
it('clamps output to [0, 255]', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, 1.5],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(0)
expect(lut[i]).toBeLessThanOrEqual(255)
}
})
})
describe('histogramToPath', () => {
it('returns empty string for empty histogram', () => {
expect(histogramToPath(new Uint32Array(0))).toBe('')
})
it('returns empty string when all bins are zero', () => {
expect(histogramToPath(new Uint32Array(256))).toBe('')
})
it('returns a closed SVG path for valid histogram', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const path = histogramToPath(histogram)
expect(path).toMatch(/^M0,1/)
expect(path).toMatch(/L1,1 Z$/)
})
it('normalizes using 99.5th percentile to suppress outliers', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = 100
histogram[255] = 100000
const path = histogramToPath(histogram)
// Most bins should map to y=0 (1 - 100/100 = 0) since
// the 99.5th percentile is 100, not the outlier 100000
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
expect(nearZero.length).toBeGreaterThan(200)
})
})

View File

@@ -0,0 +1,120 @@
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points
* without overshooting (monotone property).
*
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createMonotoneInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
const deltas: number[] = []
const slopes: number[] = []
for (let i = 0; i < n - 1; i++) {
const dx = xs[i + 1] - xs[i]
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
}
slopes.push(deltas[0] ?? 0)
for (let i = 1; i < n - 1; i++) {
if (deltas[i - 1] * deltas[i] <= 0) {
slopes.push(0)
} else {
slopes.push((deltas[i - 1] + deltas[i]) / 2)
}
}
slopes.push(deltas[n - 2] ?? 0)
for (let i = 0; i < n - 1; i++) {
if (deltas[i] === 0) {
slopes[i] = 0
slopes[i + 1] = 0
} else {
const alpha = slopes[i] / deltas[i]
const beta = slopes[i + 1] / deltas[i]
const s = alpha * alpha + beta * beta
if (s > 9) {
const t = 3 / Math.sqrt(s)
slopes[i] = t * alpha * deltas[i]
slopes[i + 1] = t * beta * deltas[i]
}
}
}
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
const t2 = t * t
const t3 = t2 * t
const h00 = 2 * t3 - 3 * t2 + 1
const h10 = t3 - 2 * t2 + t
const h01 = -2 * t3 + 3 * t2
const h11 = t3 - t2
return (
h00 * ys[lo] +
h10 * dx * slopes[lo] +
h01 * ys[hi] +
h11 * dx * slopes[hi]
)
}
}
/**
* Convert a 256-bin histogram into an SVG path string.
* Normalizes using the 99.5th percentile to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
if (!histogram.length) return ''
const sorted = Array.from(histogram).sort((a, b) => a - b)
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const step = 1 / 255
let d = 'M0,1'
for (let i = 0; i < 256; i++) {
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
}
d += ' L1,1 Z'
return d
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createMonotoneInterpolator(points)
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = interpolate(x)
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
}
return lut
}

View File

@@ -267,16 +267,6 @@
/>
</div>
</template>
<template v-if="template.vram" #top-left>
<SquareChip
:label="formatSize(template.vram)"
:title="t('templateWorkflows.vramEstimateTooltip')"
>
<template #icon>
<i class="icon-[lucide--cpu] h-3 w-3" />
</template>
</SquareChip>
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
@@ -397,7 +387,6 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { formatSize } from '@/utils/formatUtil'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -735,10 +724,6 @@ const sortOptions = computed(() => [
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
value: 'similar-to-current'
},
{
name: t('templateWorkflows.sort.popular', 'Popular'),
value: 'popular'

View File

@@ -1,367 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
DeveloperProfile,
MarketplaceTemplate,
TemplateReview,
TemplateRevenue
} from '@/types/templateMarketplace'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
const stubProfile: DeveloperProfile = {
username: '@StoneCypher',
displayName: 'Stone Cypher',
avatarUrl: undefined,
bannerUrl: undefined,
bio: 'Workflow designer',
isVerified: true,
monetizationEnabled: true,
joinedAt: new Date('2024-03-15'),
dependencies: 371,
totalDownloads: 1000,
totalFavorites: 50,
averageRating: 4.2,
templateCount: 2
}
const stubReviews: TemplateReview[] = [
{
id: 'rev-1',
authorName: 'Reviewer',
rating: 4.5,
text: 'Great work!',
createdAt: new Date('2025-10-01'),
templateId: 'tpl-1'
}
]
const stubTemplate: MarketplaceTemplate = {
id: 'tpl-1',
title: 'Test Template',
description: 'Desc',
shortDescription: 'Short',
author: {
id: 'usr-1',
name: 'Stone Cypher',
isVerified: true,
profileUrl: '/u'
},
categories: [],
tags: [],
difficulty: 'beginner',
requiredModels: [],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 0,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'mit',
version: '1.0.0',
status: 'approved',
updatedAt: new Date(),
stats: {
downloads: 500,
favorites: 30,
rating: 4,
reviewCount: 5,
weeklyTrend: 1
}
}
const stubRevenue: TemplateRevenue[] = [
{
templateId: 'tpl-1',
totalRevenue: 5000,
monthlyRevenue: 500,
currency: 'USD'
}
]
const mockService = vi.hoisted(() => ({
getCurrentUsername: vi.fn(() => '@StoneCypher'),
fetchDeveloperProfile: vi.fn(() => Promise.resolve({ ...stubProfile })),
fetchDeveloperReviews: vi.fn(() => Promise.resolve([...stubReviews])),
fetchPublishedTemplates: vi.fn(() => Promise.resolve([{ ...stubTemplate }])),
fetchTemplateRevenue: vi.fn(() => Promise.resolve([...stubRevenue])),
fetchDownloadHistory: vi.fn(() => Promise.resolve([])),
unpublishTemplate: vi.fn(() => Promise.resolve()),
saveDeveloperProfile: vi.fn((p: Partial<DeveloperProfile>) =>
Promise.resolve({ ...stubProfile, ...p })
)
}))
vi.mock('@/services/developerProfileService', () => mockService)
import DeveloperProfileDialog from './DeveloperProfileDialog.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
developerProfile: {
dialogTitle: 'Developer Profile',
username: 'Username',
bio: 'Bio',
reviews: 'Reviews',
publishedTemplates: 'Published Templates',
dependencies: 'Dependencies',
totalDownloads: 'Downloads',
totalFavorites: 'Favorites',
averageRating: 'Avg. Rating',
templateCount: 'Templates',
revenue: 'Revenue',
monthlyRevenue: 'Monthly',
totalRevenue: 'Total',
noReviews: 'No reviews yet',
noTemplates: 'No published templates yet',
unpublish: 'Unpublish',
save: 'Save Profile',
saving: 'Saving...',
verified: 'Verified',
quickActions: 'Quick Actions',
bannerPlaceholder: 'Banner image',
editUsername: 'Edit username',
editBio: 'Edit bio',
lookupHandle: 'Enter developer handle\u2026',
downloads: 'Downloads',
favorites: 'Favorites',
rating: 'Rating'
}
}
}
})
function mountDialog(props?: { username?: string }) {
return mount(DeveloperProfileDialog, {
props: {
onClose: vi.fn(),
...props
},
global: {
plugins: [i18n],
stubs: {
BaseModalLayout: {
template: `
<div data-testid="modal">
<div data-testid="header"><slot name="header" /></div>
<div data-testid="header-right"><slot name="header-right-area" /></div>
<div data-testid="content"><slot name="content" /></div>
</div>
`
},
ReviewCard: {
template: '<div data-testid="review-card" />',
props: ['review']
},
TemplateListItem: {
template:
'<div data-testid="template-list-item" :data-show-revenue="showRevenue" :data-is-current-user="isCurrentUser" />',
props: ['template', 'revenue', 'showRevenue', 'isCurrentUser']
},
DownloadHistoryChart: {
template: '<div data-testid="download-history-chart" />',
props: ['entries']
},
Button: {
template: '<button><slot /></button>',
props: ['variant', 'size', 'disabled']
}
}
}
})
}
describe('DeveloperProfileDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockService.getCurrentUsername.mockReturnValue('@StoneCypher')
mockService.fetchDeveloperProfile.mockResolvedValue({ ...stubProfile })
mockService.fetchDeveloperReviews.mockResolvedValue([...stubReviews])
mockService.fetchPublishedTemplates.mockResolvedValue([{ ...stubTemplate }])
mockService.fetchTemplateRevenue.mockResolvedValue([...stubRevenue])
})
it('renders the banner section', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="banner-section"]').exists()).toBe(true)
})
it('shows username input when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(false)
})
it('shows username text when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(false)
})
it('shows bio input when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="bio-input"]').exists()).toBe(true)
})
it('shows bio text when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
expect(wrapper.find('[data-testid="bio-text"]').exists()).toBe(true)
})
it('renders review cards', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.findAll('[data-testid="review-card"]')).toHaveLength(1)
})
it('renders template list items', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.findAll('[data-testid="template-list-item"]')).toHaveLength(
1
)
})
it('passes showRevenue=true when current user with monetization', async () => {
const wrapper = mountDialog()
await flushPromises()
const item = wrapper.find('[data-testid="template-list-item"]')
expect(item.attributes('data-show-revenue')).toBe('true')
})
it('passes showRevenue=false when not current user', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
const item = wrapper.find('[data-testid="template-list-item"]')
expect(item.attributes('data-show-revenue')).toBe('false')
})
it('shows quick actions when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(true)
})
it('hides quick actions when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(false)
})
it('shows save button when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
const headerRight = wrapper.find('[data-testid="header-right"]')
expect(headerRight.text()).toContain('Save Profile')
})
it('hides save button when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
const headerRight = wrapper.find('[data-testid="header-right"]')
expect(headerRight.text()).not.toContain('Save Profile')
})
it('renders summary stats', async () => {
const wrapper = mountDialog()
await flushPromises()
const stats = wrapper.find('[data-testid="summary-stats"]')
expect(stats.exists()).toBe(true)
expect(stats.text()).toContain('371')
expect(stats.text()).toContain('1,000')
expect(stats.text()).toContain('50')
})
it('renders the handle input with the default username', async () => {
const wrapper = mountDialog()
await flushPromises()
const handleInput = wrapper.find('[data-testid="handle-input"]')
expect(handleInput.exists()).toBe(true)
expect((handleInput.element as HTMLInputElement).value).toBe('@StoneCypher')
})
it('reloads data when the handle input changes', async () => {
const otherProfile: DeveloperProfile = {
...stubProfile,
username: '@OtherDev',
displayName: 'Other Dev',
bio: 'Another developer',
isVerified: false,
monetizationEnabled: false,
totalDownloads: 42
}
const wrapper = mountDialog()
await flushPromises()
// Initial load
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith(
'@StoneCypher'
)
vi.clearAllMocks()
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
mockService.fetchDeveloperReviews.mockResolvedValue([])
mockService.fetchPublishedTemplates.mockResolvedValue([])
const handleInput = wrapper.find('[data-testid="handle-input"]')
await handleInput.setValue('@OtherDev')
await flushPromises()
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith('@OtherDev')
expect(wrapper.find('[data-testid="username-text"]').text()).toBe(
'Other Dev'
)
})
it('clears revenue when switching to a non-current-user handle', async () => {
const wrapper = mountDialog()
await flushPromises()
// Revenue was loaded for current user
expect(mockService.fetchTemplateRevenue).toHaveBeenCalled()
vi.clearAllMocks()
const otherProfile: DeveloperProfile = {
...stubProfile,
username: '@Someone',
monetizationEnabled: false
}
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
mockService.fetchDeveloperReviews.mockResolvedValue([])
mockService.fetchPublishedTemplates.mockResolvedValue([])
const handleInput = wrapper.find('[data-testid="handle-input"]')
await handleInput.setValue('@Someone')
await flushPromises()
// Revenue should NOT be fetched for other user
expect(mockService.fetchTemplateRevenue).not.toHaveBeenCalled()
// showRevenue should be false
const item = wrapper.find('[data-testid="template-list-item"]')
expect(item.exists()).toBe(false)
})
})

View File

@@ -1,322 +0,0 @@
<template>
<BaseModalLayout :content-title="t('developerProfile.dialogTitle')" size="sm">
<template #header>
<input
v-model="viewedUsername"
type="text"
:placeholder="t('developerProfile.lookupHandle')"
class="h-8 w-48 rounded border border-border-default bg-secondary-background px-2 text-sm text-muted-foreground focus:outline-none"
data-testid="handle-input"
/>
</template>
<template v-if="isCurrentUser" #header-right-area>
<div class="mr-6">
<Button size="lg" :disabled="isSaving" @click="saveProfile">
{{
isSaving ? t('developerProfile.saving') : t('developerProfile.save')
}}
</Button>
</div>
</template>
<template #content>
<div class="flex flex-col gap-6">
<!-- Banner Image -->
<div
class="h-48 w-full overflow-hidden rounded-lg bg-secondary-background"
data-testid="banner-section"
>
<img
v-if="profile?.bannerUrl"
:src="profile.bannerUrl"
:alt="t('developerProfile.bannerPlaceholder')"
class="size-full object-cover"
/>
<div v-else class="flex size-full items-center justify-center">
<i
class="icon-[lucide--image] size-10 text-muted-foreground opacity-40"
/>
</div>
</div>
<!-- Avatar + Username + Bio -->
<div class="flex items-start gap-4" data-testid="identity-section">
<div
class="flex size-16 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
>
<i class="icon-[lucide--user] size-8 text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex items-center gap-2">
<template v-if="isCurrentUser">
<input
v-model="editableUsername"
type="text"
:placeholder="t('developerProfile.editUsername')"
class="h-8 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
data-testid="username-input"
/>
</template>
<template v-else>
<span class="text-lg font-semibold" data-testid="username-text">
{{ profile?.displayName ?? viewedUsername }}
</span>
</template>
<span
v-if="profile?.isVerified"
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-xs text-blue-400"
>
<i class="icon-[lucide--badge-check] size-3" />
{{ t('developerProfile.verified') }}
</span>
</div>
<template v-if="isCurrentUser">
<textarea
v-model="editableBio"
:placeholder="t('developerProfile.editBio')"
rows="2"
class="resize-none rounded border border-border-default bg-secondary-background px-2 py-1 text-sm text-muted-foreground focus:outline-none"
data-testid="bio-input"
/>
</template>
<template v-else>
<p
v-if="profile?.bio"
class="m-0 text-sm text-muted-foreground"
data-testid="bio-text"
>
{{ profile.bio }}
</p>
</template>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-5 gap-3" data-testid="summary-stats">
<div
v-for="stat in summaryStats"
:key="stat.label"
class="flex flex-col items-center rounded-lg bg-secondary-background p-3"
>
<span class="text-lg font-semibold">{{ stat.value }}</span>
<span class="text-xs text-muted-foreground">{{ stat.label }}</span>
</div>
</div>
<!-- Download History Chart -->
<DownloadHistoryChart
v-if="downloadHistory.length > 0"
:entries="downloadHistory"
/>
<!-- Quick Actions (current user only) -->
<div
v-if="isCurrentUser"
class="rounded-lg border border-border-default p-4"
data-testid="quick-actions"
>
<h3 class="m-0 mb-3 text-sm font-semibold">
{{ t('developerProfile.quickActions') }}
</h3>
<div class="flex flex-wrap gap-2">
<Button
v-for="tpl in templates"
:key="tpl.id"
variant="destructive-textonly"
size="sm"
@click="handleUnpublish(tpl.id)"
>
{{ t('developerProfile.unpublish') }}: {{ tpl.title }}
</Button>
</div>
</div>
<!-- Reviews Section -->
<div data-testid="reviews-section">
<h3 class="m-0 mb-3 text-sm font-semibold">
{{ t('developerProfile.reviews') }}
</h3>
<div
v-if="reviews.length === 0"
class="py-4 text-center text-sm text-muted-foreground"
>
{{ t('developerProfile.noReviews') }}
</div>
<div
v-else
class="flex max-h-80 flex-col gap-2 overflow-y-auto scrollbar-custom"
>
<ReviewCard
v-for="review in reviews"
:key="review.id"
:review="review"
/>
</div>
</div>
<!-- Published Templates Section -->
<div data-testid="templates-section">
<h3 class="m-0 mb-3 text-sm font-semibold">
{{ t('developerProfile.publishedTemplates') }}
</h3>
<div
v-if="templates.length === 0"
class="py-4 text-center text-sm text-muted-foreground"
>
{{ t('developerProfile.noTemplates') }}
</div>
<div
v-else
class="flex max-h-96 flex-col gap-2 overflow-y-auto scrollbar-custom"
>
<TemplateListItem
v-for="tpl in templates"
:key="tpl.id"
:template="tpl"
:revenue="revenueByTemplateId[tpl.id]"
:show-revenue="showRevenueColumn"
:is-current-user="isCurrentUser"
@unpublish="handleUnpublish"
/>
</div>
</div>
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { computed, provide, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import {
fetchDeveloperProfile,
fetchDeveloperReviews,
fetchDownloadHistory,
fetchPublishedTemplates,
fetchTemplateRevenue,
getCurrentUsername,
saveDeveloperProfile,
unpublishTemplate
} from '@/services/developerProfileService'
import type {
DeveloperProfile,
DownloadHistoryEntry,
MarketplaceTemplate,
TemplateReview,
TemplateRevenue
} from '@/types/templateMarketplace'
import { OnCloseKey } from '@/types/widgetTypes'
import DownloadHistoryChart from './DownloadHistoryChart.vue'
import ReviewCard from './ReviewCard.vue'
import TemplateListItem from './TemplateListItem.vue'
const { onClose, username } = defineProps<{
onClose: () => void
username?: string
}>()
const { t } = useI18n()
provide(OnCloseKey, onClose)
const viewedUsername = ref(username ?? getCurrentUsername())
const isCurrentUser = computed(
() => viewedUsername.value === getCurrentUsername()
)
const profile = ref<DeveloperProfile | null>(null)
const reviews = ref<TemplateReview[]>([])
const templates = ref<MarketplaceTemplate[]>([])
const revenue = ref<TemplateRevenue[]>([])
const downloadHistory = ref<DownloadHistoryEntry[]>([])
const isSaving = ref(false)
const editableUsername = ref('')
const editableBio = ref('')
const revenueByTemplateId = computed(() => {
const map: Record<string, TemplateRevenue> = {}
for (const entry of revenue.value) {
map[entry.templateId] = entry
}
return map
})
const showRevenueColumn = computed(
() => isCurrentUser.value && (profile.value?.monetizationEnabled ?? false)
)
const summaryStats = computed(() => [
{
label: t('developerProfile.dependencies'),
value: (profile.value?.dependencies ?? 371).toLocaleString()
},
{
label: t('developerProfile.totalDownloads'),
value: (profile.value?.totalDownloads ?? 0).toLocaleString()
},
{
label: t('developerProfile.totalFavorites'),
value: (profile.value?.totalFavorites ?? 0).toLocaleString()
},
{
label: t('developerProfile.averageRating'),
value: (profile.value?.averageRating ?? 0).toFixed(1)
},
{
label: t('developerProfile.templateCount'),
value: String(profile.value?.templateCount ?? 0)
}
])
watchDebounced(viewedUsername, () => void loadData(), { debounce: 500 })
async function loadData() {
const handle = viewedUsername.value
const [profileData, reviewsData, templatesData, historyData] =
await Promise.all([
fetchDeveloperProfile(handle),
fetchDeveloperReviews(handle),
fetchPublishedTemplates(handle),
fetchDownloadHistory(handle)
])
profile.value = profileData
reviews.value = reviewsData
templates.value = templatesData
downloadHistory.value = historyData
editableUsername.value = profileData.displayName
editableBio.value = profileData.bio ?? ''
if (isCurrentUser.value && profileData.monetizationEnabled) {
revenue.value = await fetchTemplateRevenue(handle)
} else {
revenue.value = []
}
}
async function saveProfile() {
isSaving.value = true
try {
profile.value = await saveDeveloperProfile({
...profile.value,
displayName: editableUsername.value,
bio: editableBio.value
})
} finally {
isSaving.value = false
}
}
async function handleUnpublish(templateId: string) {
await unpublishTemplate(templateId)
templates.value = templates.value.filter((t) => t.id !== templateId)
}
void loadData()
</script>

View File

@@ -1,217 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DownloadHistoryEntry } from '@/types/templateMarketplace'
const { MockChart } = vi.hoisted(() => {
const mockDestroyFn = vi.fn()
class MockChartClass {
static register = vi.fn()
static instances: MockChartClass[] = []
type: string
data: unknown
destroy = mockDestroyFn
constructor(_canvas: unknown, config: { type: string; data: unknown }) {
this.type = config.type
this.data = config.data
MockChartClass.instances.push(this)
}
}
return { MockChart: MockChartClass, mockDestroyFn }
})
vi.mock('chart.js', () => ({
Chart: MockChart,
BarController: {},
BarElement: {},
CategoryScale: {},
Filler: {},
LineController: {},
LineElement: {},
LinearScale: {},
PointElement: {},
Tooltip: {}
}))
import DownloadHistoryChart from './DownloadHistoryChart.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
developerProfile: {
downloadHistory: 'Download History',
range: {
week: 'Week',
month: 'Month',
year: 'Year',
allTime: 'All Time'
}
}
}
}
})
function makeEntries(count: number): DownloadHistoryEntry[] {
const entries: DownloadHistoryEntry[] = []
const now = new Date()
for (let i = count - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
date.setHours(0, 0, 0, 0)
entries.push({ date, downloads: 10 + i })
}
return entries
}
async function mountChart(entries?: DownloadHistoryEntry[]) {
const wrapper = mount(DownloadHistoryChart, {
props: { entries: entries ?? makeEntries(730) },
global: { plugins: [i18n] },
attachTo: document.createElement('div')
})
await nextTick()
await flushPromises()
await nextTick()
return wrapper
}
function lastInstance() {
return MockChart.instances.at(-1)
}
describe('DownloadHistoryChart', () => {
beforeEach(() => {
MockChart.instances = []
})
it('renders all four range buttons', async () => {
const wrapper = await mountChart()
const buttons = wrapper.find('[data-testid="range-buttons"]')
expect(buttons.exists()).toBe(true)
expect(wrapper.find('[data-testid="range-btn-week"]').text()).toBe('Week')
expect(wrapper.find('[data-testid="range-btn-month"]').text()).toBe('Month')
expect(wrapper.find('[data-testid="range-btn-year"]').text()).toBe('Year')
expect(wrapper.find('[data-testid="range-btn-allTime"]').text()).toBe(
'All Time'
)
})
it('defaults to week range with active styling', async () => {
const wrapper = await mountChart()
const weekBtn = wrapper.find('[data-testid="range-btn-week"]')
expect(weekBtn.classes()).toContain('font-semibold')
})
it('creates a bar chart for week range', async () => {
await mountChart()
expect(lastInstance()?.type).toBe('bar')
})
it('switches to month and creates a bar chart', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
await nextTick()
await flushPromises()
expect(lastInstance()?.type).toBe('bar')
})
it('switches to year and creates a line chart', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
await nextTick()
await flushPromises()
expect(lastInstance()?.type).toBe('line')
})
it('switches to allTime and creates a line chart', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
await nextTick()
await flushPromises()
expect(lastInstance()?.type).toBe('line')
})
it('destroys previous chart when switching ranges', async () => {
const wrapper = await mountChart()
const firstInstance = lastInstance()!
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
await nextTick()
await flushPromises()
expect(firstInstance.destroy).toHaveBeenCalled()
})
it('renders the heading text', async () => {
const wrapper = await mountChart()
expect(wrapper.text()).toContain('Download History')
})
it('passes 7 data points for week range', async () => {
await mountChart()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
expect(labels).toHaveLength(7)
})
it('passes 31 data points for month range', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
expect(labels).toHaveLength(31)
})
it('downsamples year range to weekly buckets', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
// 365 days / 7 per bucket = 52 full + 1 partial = 53
expect(labels).toHaveLength(Math.ceil(365 / 7))
})
it('downsamples allTime range to monthly buckets', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
// 730 days / 30 per bucket = 24 full + 1 partial = 25
expect(labels).toHaveLength(Math.ceil(730 / 30))
})
it('sums downloads within each aggregated bucket', async () => {
// 14 entries with downloads = 1 each, aggregated by 7 → 2 buckets of 7
const entries = makeEntries(14).map((e) => ({ ...e, downloads: 1 }))
const wrapper = mount(DownloadHistoryChart, {
props: { entries },
global: { plugins: [i18n] },
attachTo: document.createElement('div')
})
await nextTick()
await flushPromises()
await nextTick()
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const datasets = (chart.data as { datasets: { data: number[] }[] }).datasets
// 14 / 30 per bucket → 1 bucket with all 14 summed
expect(datasets[0].data).toEqual([14])
})
})

View File

@@ -1,209 +0,0 @@
<template>
<div
class="rounded-lg bg-secondary-background p-4"
data-testid="download-history-chart"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-sm font-semibold">
{{ t('developerProfile.downloadHistory') }}
</h3>
<div
class="flex gap-1 rounded-md bg-modal-panel-background p-0.5"
data-testid="range-buttons"
>
<button
v-for="range in RANGES"
:key="range"
:class="
cn(
'cursor-pointer rounded border-none px-2 py-1 text-xs transition-colors',
selectedRange === range
? 'bg-secondary-background font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
)
"
:data-testid="`range-btn-${range}`"
@click="selectedRange = range"
>
{{ t(`developerProfile.range.${range}`) }}
</button>
</div>
</div>
<div class="h-62.5">
<canvas ref="canvasRef" />
</div>
</div>
</template>
/** * Download history chart for the developer profile dashboard. * * Renders
daily download counts using Chart.js, with a toggle group in the * upper-right
corner that switches between four time ranges: * * - **Week** (7 bars) and
**Month** (31 bars) render as bar charts. * - **Year** (weekly buckets) and
**All Time** (monthly buckets) render as * filled area charts, with entries
aggregated into summed buckets to keep * the point count manageable. * * @prop
entries - Chronologically-ordered daily download history produced by * {@link
fetchDownloadHistory}. */
<script setup lang="ts">
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
LineController,
LineElement,
LinearScale,
PointElement,
Tooltip
} from 'chart.js'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
DownloadHistoryEntry,
DownloadHistoryRange
} from '@/types/templateMarketplace'
import { cn } from '@/utils/tailwindUtil'
Chart.register(
BarController,
BarElement,
CategoryScale,
Filler,
LineController,
LineElement,
LinearScale,
PointElement,
Tooltip
)
const RANGES: DownloadHistoryRange[] = ['week', 'month', 'year', 'allTime']
const BAR_COLOR = '#185A8B'
const { entries } = defineProps<{
entries: DownloadHistoryEntry[]
}>()
const { t } = useI18n()
const selectedRange = ref<DownloadHistoryRange>('week')
const canvasRef = ref<HTMLCanvasElement | null>(null)
const chartInstance = shallowRef<Chart | null>(null)
/**
* Aggregates entries into buckets by summing downloads and using the last
* date in each bucket for the label.
*/
function aggregate(
source: DownloadHistoryEntry[],
bucketSize: number
): DownloadHistoryEntry[] {
const result: DownloadHistoryEntry[] = []
for (let i = 0; i < source.length; i += bucketSize) {
const bucket = source.slice(i, i + bucketSize)
const downloads = bucket.reduce((sum, e) => sum + e.downloads, 0)
result.push({ date: bucket[bucket.length - 1].date, downloads })
}
return result
}
/**
* Returns the tail slice of entries matching the selected range, downsampled
* for larger views, along with formatted date labels.
*/
function sliceEntries(range: DownloadHistoryRange): {
labels: string[]
data: number[]
} {
const count =
range === 'week' ? 7 : range === 'month' ? 31 : range === 'year' ? 365 : 0
const sliced = count > 0 ? entries.slice(-count) : entries
const sampled =
range === 'year'
? aggregate(sliced, 7)
: range === 'allTime'
? aggregate(sliced, 30)
: sliced
const labels = sampled.map((e) => {
const d = e.date
if (range === 'week')
return d.toLocaleDateString(undefined, { weekday: 'short' })
if (range === 'month') return String(d.getDate())
if (range === 'year')
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
})
return { labels, data: sampled.map((e) => e.downloads) }
}
/**
* Builds or replaces the Chart.js instance on the canvas whenever range
* or data changes.
*/
function renderChart() {
const canvas = canvasRef.value
if (!canvas) return
chartInstance.value?.destroy()
const range = selectedRange.value
const isBar = range === 'week' || range === 'month'
const { labels, data } = sliceEntries(range)
chartInstance.value = new Chart(canvas, {
type: isBar ? 'bar' : 'line',
data: {
labels,
datasets: [
{
data,
backgroundColor: isBar ? BAR_COLOR : `${BAR_COLOR}33`,
borderColor: BAR_COLOR,
borderWidth: isBar ? 0 : 2,
borderRadius: isBar ? { topLeft: 4, topRight: 4 } : undefined,
fill: !isBar,
tension: 0.3,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
transitions: { active: { animation: { duration: 0 } } },
plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
scales: {
x: {
grid: { display: false },
ticks: {
color: '#9FA2BD',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: isBar ? undefined : 12
}
},
y: {
beginAtZero: true,
grid: { color: '#9FA2BD22' },
ticks: { color: '#9FA2BD' }
}
}
}
})
}
watch([selectedRange, () => entries], renderChart, { flush: 'post' })
watch(canvasRef, (el) => {
if (el) renderChart()
})
onBeforeUnmount(() => {
chartInstance.value?.destroy()
})
</script>

View File

@@ -1,63 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
import type { TemplateReview } from '@/types/templateMarketplace'
import ReviewCard from './ReviewCard.vue'
function makeReview(overrides?: Partial<TemplateReview>): TemplateReview {
return {
id: 'rev-1',
authorName: 'TestUser',
authorAvatarUrl: undefined,
rating: 4,
text: 'Great template!',
createdAt: new Date('2025-10-15'),
templateId: 'tpl-1',
...overrides
}
}
function mountCard(review: TemplateReview) {
return mount(ReviewCard, {
props: { review },
global: {
stubs: {
StarRating: {
template: '<span data-testid="star-rating" :data-rating="rating" />',
props: ['rating', 'size']
}
}
}
})
}
describe('ReviewCard', () => {
it('renders the author name', () => {
const wrapper = mountCard(makeReview({ authorName: 'PixelWizard' }))
expect(wrapper.text()).toContain('PixelWizard')
})
it('renders the review text', () => {
const wrapper = mountCard(makeReview({ text: 'Awesome workflow!' }))
expect(wrapper.text()).toContain('Awesome workflow!')
})
it('passes the rating to StarRating', () => {
const wrapper = mountCard(makeReview({ rating: 3.5 }))
const starRating = wrapper.find('[data-testid="star-rating"]')
expect(starRating.exists()).toBe(true)
expect(starRating.attributes('data-rating')).toBe('3.5')
})
it('renders a formatted date', () => {
const wrapper = mountCard(makeReview({ createdAt: new Date('2025-10-15') }))
expect(wrapper.text()).toContain('2025')
})
})

View File

@@ -1,38 +0,0 @@
<template>
<div class="flex flex-col gap-2 rounded-lg bg-secondary-background p-4">
<div class="flex items-center gap-2">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
>
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
</div>
<span class="text-sm font-medium">{{ review.authorName }}</span>
<StarRating :rating="review.rating" size="sm" />
<span class="ml-auto text-xs text-muted-foreground">
{{ formattedDate }}
</span>
</div>
<p class="m-0 text-sm text-muted-foreground">{{ review.text }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TemplateReview } from '@/types/templateMarketplace'
import StarRating from './StarRating.vue'
const { review } = defineProps<{
/** The review to display. */
review: TemplateReview
}>()
const formattedDate = computed(() =>
review.createdAt.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
)
</script>

View File

@@ -1,72 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
import StarRating from './StarRating.vue'
function mountRating(rating: number, size?: 'sm' | 'md') {
return mount(StarRating, {
props: { rating, size }
})
}
describe('StarRating', () => {
it('renders five star containers', () => {
const wrapper = mountRating(3)
const starContainers = wrapper.findAll('[role="img"] > div')
expect(starContainers).toHaveLength(5)
})
it('fills all stars for a rating of 5', () => {
const wrapper = mountRating(5)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(5)
for (const fill of fills) {
expect(fill.attributes('style')).toContain('width: 100%')
}
})
it('fills no stars for a rating of 0', () => {
const wrapper = mountRating(0)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(0)
})
it('renders correct fills for a half-star rating of 3.5', () => {
const wrapper = mountRating(3.5)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(4)
expect(fills[0].attributes('style')).toContain('width: 100%')
expect(fills[1].attributes('style')).toContain('width: 100%')
expect(fills[2].attributes('style')).toContain('width: 100%')
expect(fills[3].attributes('style')).toContain('width: 50%')
})
it('renders correct fills for a half-star rating of 2.5', () => {
const wrapper = mountRating(2.5)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(3)
expect(fills[0].attributes('style')).toContain('width: 100%')
expect(fills[1].attributes('style')).toContain('width: 100%')
expect(fills[2].attributes('style')).toContain('width: 50%')
})
it('uses smaller size class when size is sm', () => {
const wrapper = mountRating(3, 'sm')
const html = wrapper.html()
expect(html).toContain('size-3.5')
})
it('uses default size class when size is md', () => {
const wrapper = mountRating(3, 'md')
const html = wrapper.html()
expect(html).toContain('size-4')
})
})

View File

@@ -1,56 +0,0 @@
<template>
<div
class="inline-flex items-center gap-0.5"
role="img"
:aria-label="ariaLabel"
>
<div v-for="i in 5" :key="i" class="relative" :class="starSizeClass">
<i
:class="
cn('icon-[lucide--star]', starSizeClass, 'text-muted-foreground')
"
/>
<div
v-if="fillWidth(i) > 0"
class="absolute inset-0 overflow-hidden"
:style="{ width: `${fillWidth(i)}%` }"
>
<i
:class="cn('icon-[lucide--star]', starSizeClass, 'text-amber-400')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
const { rating, size = 'md' } = defineProps<{
/** Star rating value from 0 to 5, supporting 0.5 increments. */
rating: number
/** Visual size variant. */
size?: 'sm' | 'md'
}>()
const { t } = useI18n()
const starSizeClass = computed(() => (size === 'sm' ? 'size-3.5' : 'size-4'))
const ariaLabel = computed(
() => t('developerProfile.rating') + ': ' + String(rating) + '/5'
)
/**
* Returns the fill percentage (0, 50, or 100) for the star at position `i`.
* @param i - 1-indexed star position.
*/
function fillWidth(i: number): number {
if (rating >= i) return 100
if (rating >= i - 0.5) return 50
return 0
}
</script>

View File

@@ -1,154 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
import type {
MarketplaceTemplate,
TemplateRevenue
} from '@/types/templateMarketplace'
import TemplateListItem from './TemplateListItem.vue'
function makeTemplate(
overrides?: Partial<MarketplaceTemplate>
): MarketplaceTemplate {
return {
id: 'tpl-1',
title: 'Test Template',
description: 'Full description',
shortDescription: 'Short desc',
author: {
id: 'usr-1',
name: 'Author',
isVerified: true,
profileUrl: '/author'
},
categories: [],
tags: [],
difficulty: 'beginner',
requiredModels: [],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 0,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'mit',
version: '1.0.0',
status: 'approved',
updatedAt: new Date(),
stats: {
downloads: 1000,
favorites: 50,
rating: 4.5,
reviewCount: 10,
weeklyTrend: 2
},
...overrides
}
}
const stubRevenue: TemplateRevenue = {
templateId: 'tpl-1',
totalRevenue: 10_000,
monthlyRevenue: 1_500,
currency: 'USD'
}
interface MountOptions {
template?: MarketplaceTemplate
revenue?: TemplateRevenue
showRevenue?: boolean
isCurrentUser?: boolean
}
function mountItem(options: MountOptions = {}) {
return mount(TemplateListItem, {
props: {
template: options.template ?? makeTemplate(),
revenue: options.revenue,
showRevenue: options.showRevenue ?? false,
isCurrentUser: options.isCurrentUser ?? false
},
global: {
stubs: {
StarRating: {
template: '<span data-testid="star-rating" />',
props: ['rating', 'size']
},
Button: {
template: '<button data-testid="unpublish-button"><slot /></button>',
props: ['variant', 'size']
}
}
}
})
}
describe('TemplateListItem', () => {
it('renders the template title and description', () => {
const wrapper = mountItem({
template: makeTemplate({
title: 'My Workflow',
shortDescription: 'A cool workflow'
})
})
expect(wrapper.text()).toContain('My Workflow')
expect(wrapper.text()).toContain('A cool workflow')
})
it('renders download and favorite stats', () => {
const wrapper = mountItem({
template: makeTemplate({
stats: {
downloads: 5_000,
favorites: 200,
rating: 4,
reviewCount: 15,
weeklyTrend: 1
}
})
})
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('200')
})
it('hides revenue column when showRevenue is false', () => {
const wrapper = mountItem({
revenue: stubRevenue,
showRevenue: false
})
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(false)
})
it('shows revenue column when showRevenue is true', () => {
const wrapper = mountItem({
revenue: stubRevenue,
showRevenue: true
})
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(true)
})
it('hides unpublish button when isCurrentUser is false', () => {
const wrapper = mountItem({ isCurrentUser: false })
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(
false
)
})
it('shows unpublish button when isCurrentUser is true', () => {
const wrapper = mountItem({ isCurrentUser: true })
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(true)
})
it('emits unpublish event with template ID when button is clicked', async () => {
const wrapper = mountItem({ isCurrentUser: true })
await wrapper.find('[data-testid="unpublish-button"]').trigger('click')
expect(wrapper.emitted('unpublish')).toEqual([['tpl-1']])
})
})

View File

@@ -1,114 +0,0 @@
<template>
<div
class="flex items-center gap-4 rounded-lg bg-secondary-background p-3"
data-testid="template-list-item"
>
<div
class="size-12 shrink-0 overflow-hidden rounded bg-modal-panel-background"
>
<img
v-if="template.thumbnail"
:src="template.thumbnail"
:alt="template.title"
class="size-full object-cover"
/>
<div v-else class="flex size-full items-center justify-center">
<i class="icon-[lucide--image] size-5 text-muted-foreground" />
</div>
</div>
<div class="min-w-0 flex-1">
<h4 class="m-0 truncate text-sm font-medium">{{ template.title }}</h4>
<p class="m-0 truncate text-xs text-muted-foreground">
{{ template.shortDescription }}
</p>
</div>
<div class="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
<span
class="flex items-center gap-1"
:title="t('developerProfile.downloads')"
>
<i class="icon-[lucide--download] size-3.5" />
{{ template.stats.downloads.toLocaleString() }}
</span>
<span
class="flex items-center gap-1"
:title="t('developerProfile.favorites')"
>
<i class="icon-[lucide--heart] size-3.5" />
{{ template.stats.favorites.toLocaleString() }}
</span>
<StarRating :rating="template.stats.rating" size="sm" />
</div>
<div
v-if="showRevenue && revenue"
class="shrink-0 text-right text-xs"
data-testid="revenue-column"
>
<div class="font-medium">{{ formatCurrency(revenue.totalRevenue) }}</div>
<div class="text-muted-foreground">
{{ formatCurrency(revenue.monthlyRevenue) }}/{{
t('developerProfile.monthlyRevenue').toLowerCase()
}}
</div>
</div>
<Button
v-if="isCurrentUser"
variant="destructive-textonly"
size="sm"
data-testid="unpublish-button"
@click="emit('unpublish', template.id)"
>
{{ t('developerProfile.unpublish') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
MarketplaceTemplate,
TemplateRevenue
} from '@/types/templateMarketplace'
import StarRating from './StarRating.vue'
const {
template,
revenue,
showRevenue = false,
isCurrentUser = false
} = defineProps<{
/** The template to display. */
template: MarketplaceTemplate
/** Revenue data for this template, shown when showRevenue is true. */
revenue?: TemplateRevenue
/** Whether to display the revenue column. */
showRevenue?: boolean
/** Whether the profile being viewed belongs to the current user. */
isCurrentUser?: boolean
}>()
const emit = defineEmits<{
/** Emitted when the unpublish button is clicked. */
unpublish: [templateId: string]
}>()
const { t } = useI18n()
/**
* Formats a value in cents as a currency string.
* @param cents - Amount in cents.
*/
function formatCurrency(cents: number): string {
return (cents / 100).toLocaleString(undefined, {
style: 'currency',
currency: 'USD'
})
}
</script>

View File

@@ -71,20 +71,30 @@ function getDialogPt(item: {
</script>
<style>
@reference '../../assets/css/style.css';
.global-dialog {
max-width: calc(100vw - 1rem);
}
.global-dialog .p-dialog-header {
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
@apply pb-0;
padding: calc(var(--spacing) * 2);
padding-bottom: 0;
}
.global-dialog .p-dialog-content {
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
padding: calc(var(--spacing) * 2);
padding-top: 0;
}
@media (min-width: 1536px) {
.global-dialog .p-dialog-header {
padding: var(--p-dialog-header-padding);
padding-bottom: 0;
}
.global-dialog .p-dialog-content {
padding: var(--p-dialog-content-padding);
padding-top: 0;
}
}
/* Workspace mode: wider settings dialog */

View File

@@ -0,0 +1,173 @@
<template>
<div
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{ $t('missingModelsDialog.description') }}
</p>
<div
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="model in processedModels"
:key="model.name"
class="flex items-center justify-between px-3 py-2"
>
<div class="flex items-center gap-2 overflow-hidden">
<span
class="min-w-0 truncate text-sm text-foreground"
:title="model.name"
>
{{ model.name }}
</span>
<span
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
>
{{ model.badgeLabel }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="model.isDownloadable && fileSizes.get(model.url)"
class="text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<Button
v-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.copyURL')"
@click="void copyToClipboard(model.url)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
</div>
<div
v-if="totalDownloadSize > 0"
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
>
<span class="text-xs font-medium text-muted-foreground">
{{ $t('missingModelsDialog.totalSize') }}
</span>
<span class="text-xs text-muted-foreground">
{{ formatSize(totalDownloadSize) }}
</span>
</div>
</div>
<p
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
>
{{ $t('missingModelsDialog.footerDescription') }}
</p>
<div
v-if="hasCustomModels"
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<div class="flex flex-col gap-1">
<p
class="m-0 text-xs font-semibold leading-5 text-warning-background"
>
{{ $t('missingModelsDialog.customModelsWarning') }}
</p>
<p class="m-0 text-xs leading-5 text-warning-background">
{{ $t('missingModelsDialog.customModelsInstruction') }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { formatSize } from '@/utils/formatUtil'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
getBadgeLabel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
interface ProcessedModel {
name: string
url: string
directory: string
badgeLabel: string
isDownloadable: boolean
}
const processedModels = computed<ProcessedModel[]>(() =>
missingModels.map((model) => ({
name: model.name,
url: model.url,
directory: model.directory,
badgeLabel: getBadgeLabel(model.directory),
isDownloadable:
hasValidDirectory(model, paths) && isModelDownloadable(model)
}))
)
const hasCustomModels = computed(() =>
processedModels.value.some((m) => !m.isDownloadable)
)
const fileSizes = reactive(new Map<string, number>())
const totalDownloadSize = computed(() =>
processedModels.value
.filter((model) => model.isDownloadable)
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
)
onMounted(async () => {
const downloadableUrls = processedModels.value
.filter((m) => m.isDownloadable)
.map((m) => m.url)
await Promise.allSettled(
downloadableUrls.map(async (url) => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return
const size = response.headers.get('content-length')
if (size) fileSizes.set(url, parseInt(size, 10))
} catch {
// Silently skip size fetch failures
}
})
)
})
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex w-full flex-col gap-2 px-4 py-2">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainModels"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainModels">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="ml-6 text-sm text-muted-foreground"
>
<template #link>
<Button
variant="textonly"
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<div class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleAction">
{{ buttonLabel }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
const DIALOG_KEY = 'global-missing-models-warning'
const { t } = useI18n()
const dialogStore = useDialogStore()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
})
function openShowMissingModelsSetting() {
dialogStore.closeDialog({ key: DIALOG_KEY })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const downloadableModels = computed(() =>
missingModels.filter(
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
)
)
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
const hasCustom = computed(
() => downloadableModels.value.length < missingModels.length
)
const buttonLabel = computed(() => {
if (hasDownloadable.value && hasCustom.value)
return t('missingModelsDialog.downloadAvailable')
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
return t('missingModelsDialog.gotIt')
})
function handleAction() {
if (hasDownloadable.value) {
for (const model of downloadableModels.value) {
downloadModel(model, paths)
}
}
dialogStore.closeDialog({ key: DIALOG_KEY })
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm">
{{ $t('missingModelsDialog.title') }}
</p>
</div>
</div>
</template>

View File

@@ -1,177 +0,0 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<Suspense v-if="isDesktop">
<ElectronFileDownload
:url="option.url"
:label="option.label"
:error="option.error"
/>
</Suspense>
<FileDownload
v-else
:url="option.url"
:label="option.label"
:error="option.error"
/>
</template>
</ListBox>
</template>
<script setup lang="ts">
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {
name: string
directory: string
url: string
downloading?: boolean
completed?: boolean
progress?: number
error?: string
folder_path?: string
}
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (!paths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
downloading: false,
completed: false,
progress: 0,
error: null,
name: model.name,
directory: model.directory,
url: model.url,
folder_path: paths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!whiteListedUrls.has(model.url)) {
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
}
return {
url: model.url,
label: `${model.directory} / ${model.name}`,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path
}
})
})
onBeforeUnmount(async () => {
if (doNotAskAgain.value) {
await useSettingStore().set(
'Comfy.Workflow.ShowMissingModelsWarning',
false
)
}
})
</script>
<style scoped>
.comfy-missing-models {
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,83 @@
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:'
] as const
const ALLOWED_SUFFIXES = [
'.safetensors',
'.sft',
'.ckpt',
'.pth',
'.pt'
] as const
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const DIRECTORY_BADGE_MAP = {
vae: 'VAE',
diffusion_models: 'DIFFUSION',
text_encoders: 'TEXT ENCODER',
loras: 'LORA',
checkpoints: 'CHECKPOINT'
} as const
export interface ModelWithUrl {
name: string
url: string
directory: string
}
export function isModelDownloadable(model: ModelWithUrl): boolean {
if (WHITE_LISTED_URLS.has(model.url)) return true
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
return false
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
return false
return true
}
export function hasValidDirectory(
model: ModelWithUrl,
paths: Record<string, string[]>
): boolean {
return !!paths[model.directory]
}
export function getBadgeLabel(directory: string): string {
if (directory in DIRECTORY_BADGE_MAP) {
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
}
return directory.toUpperCase()
}
export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
}

View File

@@ -17,9 +17,9 @@
}"
@row-dblclick="editKeybinding($event.data)"
>
<Column field="actions" header="">
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions invisible flex flex-row">
<div class="actions flex flex-row">
<Button
variant="textonly"
size="icon"
@@ -56,6 +56,7 @@
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div class="truncate" :title="slotProps.data.id">
@@ -63,7 +64,11 @@
</div>
</template>
</Column>
<Column field="keybinding" :header="$t('g.keybinding')">
<Column
field="keybinding"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
@@ -75,7 +80,11 @@
<span v-else>-</span>
</template>
</Column>
<Column field="source" :header="$t('g.source')">
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
@@ -293,17 +302,3 @@ async function resetAllKeybindings() {
})
}
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-datatable-tbody) > tr > td {
@apply p-1;
min-height: 2rem;
}
:deep(.p-datatable-row-selected) .actions,
:deep(.p-datatable-selectable-row:hover) .actions {
@apply visible;
}
</style>

View File

@@ -98,16 +98,17 @@ describe('SignInForm', () => {
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
'span.text-muted.text-base.font-medium.select-none'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
'span.text-muted.text-base.font-medium.select-none'
)
// Mock getElementById to track focus
@@ -152,7 +153,7 @@ describe('SignInForm', () => {
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
'span.text-muted.text-base.font-medium.select-none'
)
// Click the forgot password link

View File

@@ -34,10 +34,13 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="cursor-pointer text-base font-medium text-muted select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
:class="
cn('text-base font-medium text-muted select-none', {
'cursor-not-allowed opacity-50':
!$form.email?.value || $form.email?.invalid,
'cursor-pointer': $form.email?.value && !$form.email?.invalid
})
"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
@@ -89,6 +92,7 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -126,11 +130,3 @@ const handleForgotPassword = async (
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -1,31 +0,0 @@
<!--
Floating indicator that displays the estimated VRAM requirement
for the currently loaded workflow graph.
-->
<template>
<div
v-if="vramEstimate > 0"
class="pointer-events-auto absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/40 px-2.5 py-1.5 text-xs font-medium text-white/90 backdrop-blur-sm"
:title="t('templateWorkflows.vramEstimateTooltip')"
>
<i class="icon-[lucide--cpu] h-3.5 w-3.5" />
{{ formatSize(vramEstimate) }}
</div>
</template>
<script setup lang="ts">
import { formatSize } from '@/utils/formatUtil'
import { ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
import { app } from '@/scripts/app'
const { t } = useI18n()
const vramEstimate = ref(0)
watchEffect(() => {
vramEstimate.value = estimateWorkflowVram(app.rootGraph)
})
</script>

View File

@@ -8,20 +8,38 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraph } from '@/scripts/defaultGraph'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
function createMockWorkflow(
overrides: Partial<LoadedComfyWorkflow> = {}
): LoadedComfyWorkflow {
return {
changeTracker: {
const workflow = new ComfyWorkflow({
path: 'workflows/color-picker-test.json',
modified: 0,
size: 0
})
const changeTracker = Object.assign(
new ChangeTracker(workflow, structuredClone(defaultGraph)),
{
checkState: vi.fn() as Mock
},
}
)
const workflowOverrides = {
changeTracker,
...overrides
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
} satisfies Partial<LoadedComfyWorkflow>
return Object.assign(workflow, workflowOverrides) as LoadedComfyWorkflow
}
// Mock the litegraph module
@@ -110,12 +128,14 @@ describe('ColorPickerButton', () => {
const wrapper = createWrapper()
const button = wrapper.find('button')
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
await button.trigger('click')
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
const picker = wrapper.findComponent({ name: 'SelectButton' })
expect(picker.exists()).toBe(true)
expect(picker.findAll('button').length).toBeGreaterThan(0)
await button.trigger('click')
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
})
})

View File

@@ -11,13 +11,17 @@
@click="() => (showColorPicker = !showColorPicker)"
>
<div class="flex items-center gap-1 px-0">
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i
class="pi pi-circle-fill"
data-testid="color-picker-current-color"
:style="{ color: currentColor ?? '' }"
/>
<i class="icon-[lucide--chevron-down]" />
</div>
</Button>
<div
v-if="showColorPicker"
class="color-picker-container absolute -top-10 left-1/2"
class="absolute -top-10 left-1/2 -translate-x-1/2"
>
<SelectButton
:model-value="selectedColorOption"
@@ -159,13 +163,7 @@ watch(
</script>
<style scoped>
@reference '../../../assets/css/style.css';
.color-picker-container {
transform: translateX(-50%);
}
:deep(.p-togglebutton) {
@apply py-2 px-1;
padding: calc(var(--spacing) * 2) var(--spacing);
}
</style>

View File

@@ -2,13 +2,14 @@
<div
v-show="widgetState.visible"
ref="widgetElement"
class="dom-widget"
class="dom-widget h-full w-full"
:title="tooltip"
:style="style"
>
<component
:is="widget.component"
v-if="isComponentWidget(widget)"
class="h-full w-full"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
@@ -174,6 +175,8 @@ const mountElementIfVisible = () => {
if (widgetElement.value.contains(widget.element)) {
return
}
widget.element.classList.add('h-full', 'w-full')
widgetElement.value.appendChild(widget.element)
}
@@ -196,11 +199,3 @@ watch(
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
</script>
<style scoped>
@reference '../../../assets/css/style.css';
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -24,9 +24,7 @@ interface Props {
modelValue: number
}
withDefaults(defineProps<Props>(), {
step: 1
})
const { label, min, max, step = 1, modelValue } = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: number]

View File

@@ -8,11 +8,14 @@
<!-- Markdown fetched successfully -->
<div
v-else-if="!error"
class="markdown-content"
class="markdown-content overflow-visible text-sm leading-(--text-sm--line-height)"
v-html="renderedHelpHtml"
/>
<!-- Fallback: markdown not found or fetch error -->
<div v-else class="fallback-content space-y-6 text-sm">
<div
v-else
class="fallback-content space-y-6 text-sm leading-(--text-sm--line-height)"
>
<p v-if="node.description">
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
</p>
@@ -22,48 +25,52 @@
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
</p>
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
<table class="overflow-x-auto">
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="outputList.length">
<p>
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
</p>
<table class="overflow-x-auto">
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
@@ -100,39 +107,59 @@ const outputList = computed(() =>
</script>
<style scoped>
@reference './../../assets/css/style.css';
.node-help-content :deep(:is(img, video)) {
@apply max-w-full h-auto block mb-4;
}
.markdown-content,
.fallback-content {
@apply text-sm overflow-visible;
display: block;
max-width: 100%;
height: auto;
margin-bottom: calc(var(--spacing) * 4);
}
.markdown-content :deep(h1),
.fallback-content h1 {
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h2),
.fallback-content h2 {
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h3),
.fallback-content h3 {
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-base);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h4),
.fallback-content h4 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h5),
.fallback-content h5 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h6),
.fallback-content h4,
.fallback-content h5,
.fallback-content h6 {
@apply mt-8 mb-4 first:mt-0;
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-xs);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(td),
@@ -155,7 +182,8 @@ const outputList = computed(() =>
.markdown-content :deep(ol),
.fallback-content ul,
.fallback-content ol {
@apply pl-8 my-2;
margin-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 8);
}
.markdown-content :deep(ul ul),
@@ -166,36 +194,42 @@ const outputList = computed(() =>
.fallback-content ol ol,
.fallback-content ul ol,
.fallback-content ol ul {
@apply pl-6 my-2;
margin-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 6);
}
.markdown-content :deep(li),
.fallback-content li {
@apply my-2;
margin-block: calc(var(--spacing) * 2);
}
.markdown-content :deep(*:first-child),
.fallback-content > *:first-child {
@apply mt-0;
margin-top: 0;
}
.markdown-content :deep(code),
.fallback-content code {
color: var(--code-text-color);
background-color: var(--code-bg-color);
@apply rounded px-1.5 py-0.5;
border-radius: var(--radius);
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
}
.markdown-content :deep(table),
.fallback-content table {
@apply w-full border-collapse;
border-collapse: collapse;
}
.fallback-content table {
width: 100%;
}
.markdown-content :deep(th),
.markdown-content :deep(td),
.fallback-content th,
.fallback-content td {
@apply px-2 py-2;
padding: calc(var(--spacing) * 2);
}
.markdown-content :deep(tr),
@@ -215,16 +249,22 @@ const outputList = computed(() =>
.markdown-content :deep(pre),
.fallback-content pre {
@apply rounded p-4 my-4 overflow-x-auto;
margin-block: calc(var(--spacing) * 4);
overflow-x: auto;
border-radius: var(--radius);
padding: calc(var(--spacing) * 4);
background-color: var(--code-block-bg-color);
code {
@apply bg-transparent p-0;
background-color: transparent;
padding: 0;
color: var(--p-text-color);
}
}
.markdown-content :deep(table) {
@apply overflow-x-auto;
display: block;
width: 100%;
overflow-x: auto;
}
</style>

View File

@@ -75,15 +75,10 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'active' | 'expanded'
const props = withDefaults(
defineProps<{
expanded?: boolean
menuHovered?: boolean
}>(),
{
menuHovered: false
}
)
const { expanded, menuHovered = false } = defineProps<{
expanded?: boolean
menuHovered?: boolean
}>()
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
@@ -106,13 +101,12 @@ const {
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
const isOverlayHovered = computed(() => isHovered.value || menuHovered)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () =>
props.expanded === undefined ? internalExpanded.value : props.expanded,
get: () => (expanded === undefined ? internalExpanded.value : expanded),
set: (value) => {
if (props.expanded === undefined) {
if (expanded === undefined) {
internalExpanded.value = value
}
emit('update:expanded', value)

View File

@@ -17,10 +17,7 @@
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover
:job-id="props.jobId"
:workflow-id="props.workflowId"
/>
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
</div>
</Teleport>
<Teleport to="body">
@@ -36,7 +33,7 @@
>
<QueueAssetPreview
:image-url="iconImageUrl!"
:name="props.title"
:name="title"
:time-label="rightText || undefined"
@image-click="emit('view')"
/>
@@ -49,23 +46,20 @@
>
<div
v-if="
props.state === 'running' &&
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
state === 'running' &&
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
"
:class="progressBarContainerClass"
>
<div
v-if="hasProgressPercent(props.progressTotalPercent)"
v-if="hasProgressPercent(progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(props.progressTotalPercent)"
:style="progressPercentStyle(progressTotalPercent)"
/>
<div
v-if="hasProgressPercent(props.progressCurrentPercent)"
v-if="hasProgressPercent(progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(props.progressCurrentPercent)"
:style="progressPercentStyle(progressCurrentPercent)"
/>
</div>
@@ -93,8 +87,8 @@
</div>
<div class="relative z-1 min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
<div class="truncate opacity-90" :title="title">
<slot name="primary">{{ title }}</slot>
</div>
</div>
@@ -131,7 +125,7 @@
class="inline-flex items-center gap-2 pr-1"
>
<Button
v-if="props.state === 'failed' && computedShowClear"
v-if="state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
@@ -142,8 +136,8 @@
</Button>
<Button
v-else-if="
props.state !== 'completed' &&
props.state !== 'running' &&
state !== 'completed' &&
state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
@@ -155,14 +149,14 @@
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="props.state === 'completed'"
v-else-if="state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<Button
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-if="showMenu !== undefined ? showMenu : true"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
@@ -172,17 +166,13 @@
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</div>
<div
v-else-if="props.state !== 'running'"
key="secondary"
class="pr-2"
>
<slot name="secondary">{{ props.rightText }}</slot>
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
<slot name="secondary">{{ rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<Button
v-if="props.state === 'running' && computedShowClear"
v-if="state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
@@ -209,34 +199,33 @@ import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
}>(),
{
workflowId: undefined,
rightText: '',
iconName: undefined,
iconImageUrl: undefined,
showClear: undefined,
showMenu: undefined,
progressTotalPercent: undefined,
progressCurrentPercent: undefined,
runningNodeName: undefined,
activeDetailsId: null
}
)
const {
jobId,
workflowId,
state,
title,
rightText = '',
iconName,
iconImageUrl,
showClear,
showMenu,
progressTotalPercent,
progressCurrentPercent,
activeDetailsId = null
} = defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
}>()
const emit = defineEmits<{
(e: 'cancel'): void
@@ -262,14 +251,14 @@ const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => props.activeDetailsId === props.jobId)
const showDetails = computed(() => activeDetailsId === jobId)
const onRowEnter = () => {
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
if (!isPreviewVisible.value) emit('details-enter', jobId)
}
const onRowLeave = () => emit('details-leave', props.jobId)
const onPopoverEnter = () => emit('details-enter', props.jobId)
const onPopoverLeave = () => emit('details-leave', props.jobId)
const onRowLeave = () => emit('details-leave', jobId)
const onPopoverEnter = () => emit('details-enter', jobId)
const onPopoverLeave = () => emit('details-leave', jobId)
const isPreviewVisible = ref(false)
const previewHideTimer = ref<number | null>(null)
@@ -286,9 +275,7 @@ const clearPreviewShowTimer = () => {
previewShowTimer.value = null
}
}
const canShowPreview = computed(
() => props.state === 'completed' && !!props.iconImageUrl
)
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
const scheduleShowPreview = () => {
if (!canShowPreview.value) return
clearPreviewHideTimer()
@@ -343,23 +330,23 @@ watch(
const isHovered = ref(false)
const iconClass = computed(() => {
if (props.iconName) return props.iconName
return iconForJobState(props.state)
if (iconName) return iconName
return iconForJobState(state)
})
const shouldSpin = computed(
() =>
props.state === 'pending' &&
state === 'pending' &&
iconClass.value === iconForJobState('pending') &&
!props.iconImageUrl
!iconImageUrl
)
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
if (showClear !== undefined) return showClear
return state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', props.jobId)
const emitDetailsLeave = () => emit('details-leave', jobId)
const onCancelClick = () => {
emitDetailsLeave()
@@ -372,7 +359,7 @@ const onDeleteClick = () => {
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
const shouldShowMenu = showMenu !== undefined ? showMenu : true
if (shouldShowMenu) emit('menu', event)
}
</script>

View File

@@ -11,21 +11,22 @@ interface Props {
disable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
duration: 150,
easingEnter: 'ease-in-out',
easingLeave: 'ease-in-out',
opacityClosed: 0,
opacityOpened: 1
})
const {
duration = 150,
easingEnter = 'ease-in-out',
easingLeave = 'ease-in-out',
opacityClosed = 0,
opacityOpened = 1,
disable
} = defineProps<Props>()
const closed = '0px'
const isMounted = ref(false)
onMounted(() => (isMounted.value = true))
const duration = computed(() =>
isMounted.value && !props.disable ? props.duration : 0
const animationDuration = computed(() =>
isMounted.value && !disable ? duration : 0
)
interface initialStyle {
@@ -95,7 +96,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
return [
{
height: closed,
opacity: props.opacityClosed,
opacity: opacityClosed,
paddingTop: closed,
paddingBottom: closed,
borderTopWidth: closed,
@@ -105,7 +106,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
},
{
height,
opacity: props.opacityOpened,
opacity: opacityOpened,
paddingTop: initialStyle.paddingTop,
paddingBottom: initialStyle.paddingBottom,
borderTopWidth: initialStyle.borderTopWidth,
@@ -121,7 +122,7 @@ function enterTransition(element: Element, done: () => void) {
const initialStyle = getElementStyle(HTMLElement)
const height = prepareElement(HTMLElement, initialStyle)
const keyframes = getEnterKeyframes(height, initialStyle)
const options = { duration: duration.value, easing: props.easingEnter }
const options = { duration: animationDuration.value, easing: easingEnter }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
@@ -132,7 +133,7 @@ function leaveTransition(element: Element, done: () => void) {
HTMLElement.style.height = height
HTMLElement.style.overflow = 'hidden'
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
const options = { duration: duration.value, easing: props.easingLeave }
const options = { duration: animationDuration.value, easing: easingLeave }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="_content">
<div class="flex flex-col gap-2">
<SelectButton
v-model="selectedFilter"
class="filter-type-select"
@@ -16,7 +16,7 @@
auto-filter-focus
/>
</div>
<div class="_footer">
<div class="flex flex-col items-end pt-4">
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
</div>
</template>
@@ -67,15 +67,3 @@ const submit = () => {
})
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
._content {
@apply flex flex-col space-y-2;
}
._footer {
@apply flex flex-col pt-4 items-end;
}
</style>

View File

@@ -255,8 +255,6 @@ onMounted(() => {
</style>
<style scoped>
@reference "tailwindcss";
.floating-sidebar {
padding: var(--sidebar-padding);
}

View File

@@ -15,7 +15,7 @@
:aria-label="computedTooltip"
@click="emit('click', $event)"
>
<div class="side-bar-button-content">
<div class="side-bar-button-content flex flex-col items-center gap-2">
<slot name="icon">
<div class="sidebar-icon-wrapper relative">
<i
@@ -40,9 +40,11 @@
</span>
</div>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
st(label, label)
}}</span>
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-[10px]"
>{{ st(label, label) }}</span
>
</div>
</Button>
</template>
@@ -104,8 +106,6 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</style>
<style scoped>
@reference '../../assets/css/style.css';
.side-bar-button {
width: var(--sidebar-width);
height: var(--sidebar-item-height);
@@ -117,12 +117,7 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
height: var(--sidebar-width);
}
.side-bar-button-content {
@apply flex flex-col items-center gap-2;
}
.side-bar-button-label {
@apply text-[10px] text-center;
line-height: 1;
}

View File

@@ -11,6 +11,7 @@
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
:pt="sidebarPt"
>
<template #start>
<span class="truncate font-bold" :title="props.title">
@@ -20,7 +21,7 @@
</template>
<template #end>
<div
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
class="touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
>
<slot name="tool-buttons" />
</div>
@@ -54,19 +55,10 @@ const props = defineProps<{
title: string
class?: string
}>()
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'
}
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>
<style scoped>
@reference '../../../assets/css/style.css';
:deep(.p-toolbar-end) .p-button {
@apply py-1 2xl:py-2;
}
:deep(.p-toolbar-start) {
@apply min-w-0 flex-1 overflow-hidden;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="node-lib-node-container"
class="node-lib-node-container h-full w-full"
data-testid="node-tree-leaf"
:data-node-name="nodeDef.display_name"
>
@@ -206,11 +206,3 @@ onUnmounted(() => {
nodeContentElement.value?.removeEventListener('mouseleave', handleMouseLeave)
})
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
.node-lib-node-container {
@apply h-full w-full;
}
</style>

View File

@@ -1,109 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import type { CachedAsset } from '@/types/templateMarketplace'
import TemplateAssetUploadZone from './TemplateAssetUploadZone.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
previewGeneration: {
uploadPrompt: 'Click to upload',
removeFile: 'Remove'
}
}
}
}
}
})
function makeAsset(name: string): CachedAsset {
return {
file: new File(['data'], name, { type: 'image/png' }),
objectUrl: `blob:http://localhost/${name}`,
originalName: name
}
}
function mountZone(props: Record<string, unknown> = {}) {
return mount(TemplateAssetUploadZone, {
props,
global: { plugins: [i18n] }
})
}
describe('TemplateAssetUploadZone', () => {
it('shows the upload prompt when no asset is provided', () => {
const wrapper = mountZone()
expect(wrapper.text()).toContain('Click to upload')
expect(wrapper.find('img').exists()).toBe(false)
})
it('shows an image preview when an asset is provided', () => {
const asset = makeAsset('photo.png')
const wrapper = mountZone({ asset })
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe(asset.objectUrl)
expect(wrapper.text()).toContain('photo.png')
})
it('shows a video element when previewType is video', () => {
const asset = makeAsset('demo.mp4')
const wrapper = mountZone({ asset, previewType: 'video' })
expect(wrapper.find('video').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
})
it('emits upload with the selected file', async () => {
const wrapper = mountZone()
const input = wrapper.find('input[type="file"]')
const file = new File(['bytes'], 'test.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { value: [file] })
await input.trigger('change')
expect(wrapper.emitted('upload')).toHaveLength(1)
expect(wrapper.emitted('upload')![0]).toEqual([file])
})
it('emits remove when the remove button is clicked', async () => {
const wrapper = mountZone({ asset: makeAsset('photo.png') })
const removeBtn = wrapper.find('button[aria-label="Remove"]')
await removeBtn.trigger('click')
expect(wrapper.emitted('remove')).toHaveLength(1)
})
it('applies the provided sizeClass to the upload zone', () => {
const wrapper = mountZone({ sizeClass: 'h-40 w-64' })
const zone = wrapper.find('[role="button"]')
expect(zone.classes()).toContain('h-40')
expect(zone.classes()).toContain('w-64')
})
it('uses image/* accept filter by default', () => {
const wrapper = mountZone()
const input = wrapper.find('input[type="file"]')
expect(input.attributes('accept')).toBe('image/*')
})
it('applies a custom accept filter', () => {
const wrapper = mountZone({ accept: 'video/*' })
const input = wrapper.find('input[type="file"]')
expect(input.attributes('accept')).toBe('video/*')
})
})

View File

@@ -1,109 +0,0 @@
<!--
Reusable upload zone for a single file asset. Shows a dashed click-to-upload
area when empty, and a preview with filename overlay when populated.
-->
<template>
<div>
<div
v-if="!asset"
:class="
cn(
'flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground',
sizeClass
)
"
role="button"
:tabindex="0"
:aria-label="t('templatePublishing.steps.previewGeneration.uploadPrompt')"
@click="fileInput?.click()"
@keydown.enter="fileInput?.click()"
>
<div class="flex flex-col items-center gap-1 text-muted-foreground">
<i class="icon-[lucide--upload] h-5 w-5" />
<span class="text-xs">
{{ t('templatePublishing.steps.previewGeneration.uploadPrompt') }}
</span>
</div>
</div>
<div
v-else
:class="cn('group relative overflow-hidden rounded-lg', sizeClass)"
>
<img
v-if="previewType === 'image'"
:src="asset.objectUrl"
:alt="asset.originalName"
class="h-full w-full object-cover"
/>
<video
v-else
:src="asset.objectUrl"
controls
class="h-full w-full object-cover"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<span class="truncate text-xs text-white">
{{ asset.originalName }}
</span>
<button
type="button"
class="shrink-0 text-white hover:text-danger"
:aria-label="
t('templatePublishing.steps.previewGeneration.removeFile')
"
@click="emit('remove')"
>
<i class="icon-[lucide--x] h-4 w-4" />
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
@change="onFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CachedAsset } from '@/types/templateMarketplace'
import { cn } from '@/utils/tailwindUtil'
const {
asset = null,
accept = 'image/*',
previewType = 'image',
sizeClass = 'h-32 w-48'
} = defineProps<{
asset?: CachedAsset | null
accept?: string
previewType?: 'image' | 'video'
sizeClass?: string
}>()
const emit = defineEmits<{
upload: [file: File]
remove: []
}>()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
emit('upload', file)
input.value = ''
}
}
</script>

View File

@@ -1,182 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
vi.mock(
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
() => ({
loadTemplateUnderway: vi.fn(() => null),
saveTemplateUnderway: vi.fn()
})
)
import TemplatePublishingDialog from './TemplatePublishingDialog.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
dialogTitle: 'Template Publishing',
next: 'Next',
previous: 'Previous',
saveDraft: 'Save Draft',
stepProgress: 'Step {current} of {total}',
steps: {
landing: {
title: 'Getting Started',
description: 'Overview of the publishing process'
},
metadata: {
title: 'Metadata',
description: 'Title, description, and author info'
},
description: {
title: 'Description',
description: 'Write a detailed description of your template'
},
previewGeneration: {
title: 'Preview',
description: 'Generate preview images and videos'
},
categoryAndTagging: {
title: 'Categories & Tags',
description: 'Categorize and tag your template'
},
preview: {
title: 'Preview',
description: 'Review your template before submitting'
},
submissionForReview: {
title: 'Submit',
description: 'Submit your template for review'
},
complete: {
title: 'Complete',
description: 'Your template has been submitted'
}
}
}
}
}
})
function mountDialog(props?: { initialPage?: string }) {
return mount(TemplatePublishingDialog, {
props: {
onClose: vi.fn(),
...props
},
global: {
plugins: [i18n],
stubs: {
BaseModalLayout: {
template: `
<div data-testid="modal">
<div data-testid="left-panel"><slot name="leftPanel" /></div>
<div data-testid="header"><slot name="header" /></div>
<div data-testid="header-right"><slot name="header-right-area" /></div>
<div data-testid="content"><slot name="content" /></div>
</div>
`
},
TemplatePublishingStepperNav: {
template: '<div data-testid="stepper-nav" />',
props: ['currentStep', 'stepDefinitions']
},
StepTemplatePublishingLanding: {
template: '<div data-testid="step-landing" />'
},
StepTemplatePublishingMetadata: {
template: '<div data-testid="step-metadata" />'
},
StepTemplatePublishingDescription: {
template: '<div data-testid="step-description" />'
},
StepTemplatePublishingPreviewGeneration: {
template: '<div data-testid="step-preview-generation" />'
},
StepTemplatePublishingCategoryAndTagging: {
template: '<div data-testid="step-category" />'
},
StepTemplatePublishingPreview: {
template: '<div data-testid="step-preview" />'
},
StepTemplatePublishingSubmissionForReview: {
template: '<div data-testid="step-submission" />'
},
StepTemplatePublishingComplete: {
template: '<div data-testid="step-complete" />'
}
}
}
})
}
describe('TemplatePublishingDialog', () => {
it('renders the dialog with the first step by default', () => {
const wrapper = mountDialog()
expect(wrapper.find('[data-testid="modal"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
})
it('renders the stepper nav in the left panel', () => {
const wrapper = mountDialog()
const leftPanel = wrapper.find('[data-testid="left-panel"]')
expect(leftPanel.find('[data-testid="stepper-nav"]').exists()).toBe(true)
})
it('maps initialPage to the correct starting step', () => {
const wrapper = mountDialog({ initialPage: 'metadata' })
expect(wrapper.find('[data-testid="step-metadata"]').exists()).toBe(true)
})
it('defaults to step 1 for unknown initialPage', () => {
const wrapper = mountDialog({ initialPage: 'nonexistent' })
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
})
it('shows Previous button when not on first step', () => {
const wrapper = mountDialog({ initialPage: 'metadata' })
const headerRight = wrapper.find('[data-testid="header-right"]')
const buttons = headerRight.findAll('button')
const buttonTexts = buttons.map((b) => b.text())
expect(buttonTexts.some((text) => text.includes('Previous'))).toBe(true)
})
it('disables Previous button on first step', () => {
const wrapper = mountDialog()
const headerRight = wrapper.find('[data-testid="header-right"]')
const prevButton = headerRight
.findAll('button')
.find((b) => b.text().includes('Previous'))
expect(prevButton?.attributes('disabled')).toBeDefined()
})
it('disables Next button on last step', () => {
const wrapper = mountDialog({
initialPage: 'complete'
})
const headerRight = wrapper.find('[data-testid="header-right"]')
const nextButton = headerRight
.findAll('button')
.find((b) => b.text().includes('Next'))
expect(nextButton?.attributes('disabled')).toBeDefined()
})
it('disables Next button on submit step', () => {
const wrapper = mountDialog({
initialPage: 'submissionForReview'
})
const headerRight = wrapper.find('[data-testid="header-right"]')
const nextButton = headerRight
.findAll('button')
.find((b) => b.text().includes('Next'))
expect(nextButton?.attributes('disabled')).toBeDefined()
})
})

View File

@@ -1,152 +0,0 @@
<template>
<BaseModalLayout
:content-title="t('templatePublishing.dialogTitle')"
size="md"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--upload]" />
<h2 class="text-neutral text-base">
{{ t('templatePublishing.dialogTitle') }}
</h2>
</template>
<template #leftPanel>
<TemplatePublishingStepperNav
:current-step="currentStep"
:step-definitions="stepDefinitions"
@update:current-step="goToStep"
/>
</template>
<template #header>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{{
t('templatePublishing.stepProgress', {
current: currentStep,
total: totalSteps
})
}}
</span>
</div>
</template>
<template #header-right-area>
<div class="mr-6 flex gap-2">
<Button
:disabled="isFirstStep"
variant="secondary"
size="lg"
@click="prevStep"
>
<i class="icon-[lucide--arrow-left]" />
{{ t('templatePublishing.previous') }}
</Button>
<Button
:disabled="
currentStep >= totalSteps - 1 ||
currentStep === STEP_PAGE_MAP.preview
"
size="lg"
@click="nextStep"
>
{{ t('templatePublishing.next') }}
<i class="icon-[lucide--arrow-right]" />
</Button>
</div>
</template>
<template #content>
<component :is="activeStepComponent" />
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import { OnCloseKey } from '@/types/widgetTypes'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import { useTemplatePublishingStepper } from '@/composables/useTemplatePublishingStepper'
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
import StepTemplatePublishingCategoryAndTagging from './steps/StepTemplatePublishingCategoryAndTagging.vue'
import StepTemplatePublishingComplete from './steps/StepTemplatePublishingComplete.vue'
import StepTemplatePublishingDescription from './steps/StepTemplatePublishingDescription.vue'
import StepTemplatePublishingLanding from './steps/StepTemplatePublishingLanding.vue'
import StepTemplatePublishingMetadata from './steps/StepTemplatePublishingMetadata.vue'
import StepTemplatePublishingPreview from './steps/StepTemplatePublishingPreview.vue'
import StepTemplatePublishingPreviewGeneration from './steps/StepTemplatePublishingPreviewGeneration.vue'
import StepTemplatePublishingSubmissionForReview from './steps/StepTemplatePublishingSubmissionForReview.vue'
import { PublishingStepperKey } from './types'
const { onClose, initialPage } = defineProps<{
onClose: () => void
initialPage?: string
}>()
const { t } = useI18n()
provide(OnCloseKey, onClose)
const STEP_PAGE_MAP: Record<string, number> = {
publishingLanding: 1,
metadata: 2,
description: 3,
previewGeneration: 4,
categoryAndTagging: 5,
preview: 6,
submissionForReview: 7,
complete: 8
}
const initialStep = initialPage ? (STEP_PAGE_MAP[initialPage] ?? 1) : 1
const {
currentStep,
totalSteps,
template,
stepDefinitions,
isFirstStep,
isLastStep,
canProceed,
nextStep,
prevStep,
goToStep,
saveDraft,
setStepValid
} = useTemplatePublishingStepper({ initialStep })
const STEP_COMPONENTS: Component[] = [
StepTemplatePublishingLanding,
StepTemplatePublishingMetadata,
StepTemplatePublishingDescription,
StepTemplatePublishingPreviewGeneration,
StepTemplatePublishingCategoryAndTagging,
StepTemplatePublishingPreview,
StepTemplatePublishingSubmissionForReview,
StepTemplatePublishingComplete
]
const activeStepComponent = computed(
() => STEP_COMPONENTS[currentStep.value - 1]
)
provide(PublishingStepperKey, {
currentStep,
totalSteps,
isFirstStep,
isLastStep,
canProceed,
template,
nextStep,
prevStep,
goToStep,
saveDraft,
setStepValid
})
</script>

View File

@@ -1,103 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import type { PublishingStepDefinition } from './types'
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
const STEP_DEFINITIONS: PublishingStepDefinition[] = [
{
number: 1,
titleKey: 'steps.landing.title',
descriptionKey: 'steps.landing.description'
},
{
number: 2,
titleKey: 'steps.metadata.title',
descriptionKey: 'steps.metadata.description'
},
{
number: 3,
titleKey: 'steps.preview.title',
descriptionKey: 'steps.preview.description'
}
]
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
steps: {
landing: { title: 'Getting Started', description: '' },
metadata: { title: 'Metadata', description: '' },
preview: { title: 'Preview', description: '' }
}
}
}
})
function mountNav(props?: { currentStep?: number }) {
return mount(TemplatePublishingStepperNav, {
props: {
currentStep: props?.currentStep ?? 1,
stepDefinitions: STEP_DEFINITIONS
},
global: { plugins: [i18n] }
})
}
describe('TemplatePublishingStepperNav', () => {
it('renders a button for each step definition', () => {
const wrapper = mountNav()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(STEP_DEFINITIONS.length)
})
it('displays translated step titles', () => {
const wrapper = mountNav()
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toContain('Getting Started')
expect(buttons[1].text()).toContain('Metadata')
expect(buttons[2].text()).toContain('Preview')
})
it('marks the current step button as aria-selected', () => {
const wrapper = mountNav({ currentStep: 2 })
const buttons = wrapper.findAll('button')
expect(buttons[0].attributes('aria-selected')).toBe('false')
expect(buttons[1].attributes('aria-selected')).toBe('true')
expect(buttons[2].attributes('aria-selected')).toBe('false')
})
it('shows a check icon for completed steps', () => {
const wrapper = mountNav({ currentStep: 3 })
const buttons = wrapper.findAll('button')
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
expect(buttons[1].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
expect(buttons[2].find('i.icon-\\[lucide--check\\]').exists()).toBe(false)
})
it('shows step numbers for current and future steps', () => {
const wrapper = mountNav({ currentStep: 2 })
const buttons = wrapper.findAll('button')
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
expect(buttons[1].text()).toContain('2')
expect(buttons[2].text()).toContain('3')
})
it('emits update:currentStep when a step button is clicked', async () => {
const wrapper = mountNav({ currentStep: 1 })
await wrapper.findAll('button')[1].trigger('click')
expect(wrapper.emitted('update:currentStep')).toEqual([[2]])
})
it('renders separators between steps', () => {
const wrapper = mountNav()
const separators = wrapper.findAll('div.bg-border-default')
expect(separators).toHaveLength(STEP_DEFINITIONS.length - 1)
})
})

View File

@@ -1,83 +0,0 @@
<template>
<nav
class="flex flex-col gap-1 px-4 py-2"
role="tablist"
aria-orientation="vertical"
>
<template v-for="(step, index) in stepDefinitions" :key="step.number">
<button
role="tab"
:aria-selected="step.number === currentStep"
:class="
cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm',
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2',
step.number === currentStep &&
step.number === stepDefinitions.length &&
'bg-blue-900 font-medium text-neutral',
step.number === currentStep &&
step.number < stepDefinitions.length &&
'font-medium text-neutral',
step.number < currentStep && 'bg-green-900 text-muted-foreground',
step.number > currentStep && 'text-muted-foreground opacity-50'
)
"
:disabled="step.number === stepDefinitions.length"
@click="emit('update:currentStep', step.number)"
>
<span
:class="
cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs',
step.number === currentStep &&
'bg-comfy-accent text-comfy-accent-foreground',
step.number < currentStep && 'bg-comfy-accent/20 text-neutral',
step.number > currentStep &&
'bg-secondary-background text-muted-foreground'
)
"
>
<i
v-if="step.number < currentStep"
class="icon-[lucide--check] h-3.5 w-3.5"
/>
<span v-else>{{ step.number }}</span>
</span>
<span class="leading-tight">
{{ t(step.titleKey)
}}<template
v-if="
step.number === currentStep &&
step.number === stepDefinitions.length
"
>
&#127881;</template
>
</span>
</button>
<div
v-if="index < stepDefinitions.length - 1"
class="bg-border-default ml-5 h-4 w-px"
/>
</template>
</nav>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { PublishingStepDefinition } from './types'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
currentStep: number
stepDefinitions: PublishingStepDefinition[]
}>()
const emit = defineEmits<{
'update:currentStep': [step: number]
}>()
const { t } = useI18n()
</script>

View File

@@ -1,189 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingCategoryAndTagging from './StepTemplatePublishingCategoryAndTagging.vue'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
metadata: {
categoryLabel: 'Categories',
tagsLabel: 'Tags',
tagsPlaceholder: 'Type to search tags…',
category: {
imageGeneration: 'Image Generation',
videoGeneration: 'Video Generation',
audio: 'Audio',
text: 'Text',
threeD: '3D',
upscaling: 'Upscaling',
inpainting: 'Inpainting',
controlNet: 'ControlNet',
styleTransfer: 'Style Transfer',
other: 'Other'
}
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(5)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingCategoryAndTagging, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingCategoryAndTagging', () => {
it('renders category and tag labels', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Categories')
expect(wrapper.text()).toContain('Tags')
})
it('renders all category checkboxes', () => {
const { wrapper } = mountStep()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(10)
expect(wrapper.text()).toContain('Image Generation')
expect(wrapper.text()).toContain('ControlNet')
})
it('toggles category when checkbox is clicked', async () => {
const ctx = createContext({ categories: [] })
const { wrapper } = mountStep(ctx)
const checkbox = wrapper.find('#tpl-category-audio')
await checkbox.setValue(true)
expect(ctx.template.value.categories).toContain('audio')
await checkbox.setValue(false)
expect(ctx.template.value.categories).not.toContain('audio')
})
it('preserves existing categories when toggling', async () => {
const ctx = createContext({ categories: ['text', '3d'] })
const { wrapper } = mountStep(ctx)
const audioCheckbox = wrapper.find('#tpl-category-audio')
await audioCheckbox.setValue(true)
expect(ctx.template.value.categories).toContain('text')
expect(ctx.template.value.categories).toContain('3d')
expect(ctx.template.value.categories).toContain('audio')
})
it('adds a tag when pressing enter in the tags input', async () => {
const ctx = createContext({ tags: [] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('my-tag')
await tagInput.trigger('keydown.enter')
expect(ctx.template.value.tags).toContain('my-tag')
})
it('does not add duplicate tags', async () => {
const ctx = createContext({ tags: ['existing'] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('existing')
await tagInput.trigger('keydown.enter')
expect(ctx.template.value.tags).toEqual(['existing'])
})
it('removes a tag when the remove button is clicked', async () => {
const ctx = createContext({ tags: ['alpha', 'beta'] })
const { wrapper } = mountStep(ctx)
const removeButtons = wrapper.findAll('button[aria-label^="Remove tag"]')
await removeButtons[0].trigger('click')
expect(ctx.template.value.tags).toEqual(['beta'])
})
it('shows filtered suggestions when typing in tags input', async () => {
const ctx = createContext({ tags: [] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('flux')
await tagInput.trigger('focus')
const suggestions = wrapper.findAll('li')
expect(suggestions.length).toBeGreaterThan(0)
expect(suggestions[0].text()).toBe('flux')
})
it('adds a suggestion tag when clicking it', async () => {
const ctx = createContext({ tags: [] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('flux')
await tagInput.trigger('focus')
const suggestion = wrapper.find('li')
await suggestion.trigger('mousedown')
expect(ctx.template.value.tags).toContain('flux')
})
})

View File

@@ -1,179 +0,0 @@
<template>
<div class="flex flex-col gap-6 p-6">
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-category-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.categoryLabel') }}
</span>
</div>
<div
class="flex flex-wrap gap-2"
role="group"
aria-labelledby="tpl-category-label"
>
<label
v-for="cat in CATEGORIES"
:key="cat.value"
:for="`tpl-category-${cat.value}`"
class="flex cursor-pointer items-center gap-1.5 text-sm"
>
<input
:id="`tpl-category-${cat.value}`"
type="checkbox"
:checked="ctx.template.value.categories?.includes(cat.value)"
@change="toggleCategory(cat.value)"
/>
{{ t(`templatePublishing.steps.metadata.category.${cat.key}`) }}
</label>
</div>
</div>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-tags-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.tagsLabel') }}
</span>
</div>
<div class="flex flex-col gap-1">
<div
v-if="(ctx.template.value.tags ?? []).length > 0"
class="flex max-h-20 flex-wrap gap-1 overflow-y-auto scrollbar-custom"
>
<span
v-for="tag in ctx.template.value.tags ?? []"
:key="tag"
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ tag }}
<button
type="button"
class="hover:text-danger"
:aria-label="`Remove tag ${tag}`"
@click="removeTag(tag)"
>
<i class="icon-[lucide--x] h-3 w-3" />
</button>
</span>
</div>
<div class="relative">
<input
v-model="tagQuery"
type="text"
class="h-8 w-44 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
:placeholder="
t('templatePublishing.steps.metadata.tagsPlaceholder')
"
aria-labelledby="tpl-tags-label"
@focus="showSuggestions = true"
@keydown.enter.prevent="addTag(tagQuery)"
/>
<ul
v-if="showSuggestions && filteredSuggestions.length > 0"
class="absolute z-10 mt-1 max-h-40 w-44 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
>
<li
v-for="suggestion in filteredSuggestions"
:key="suggestion"
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
@mousedown.prevent="addTag(suggestion)"
>
{{ suggestion }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const CATEGORIES = [
{ key: 'threeD', value: '3d' },
{ key: 'audio', value: 'audio' },
{ key: 'controlNet', value: 'controlnet' },
{ key: 'imageGeneration', value: 'image-generation' },
{ key: 'inpainting', value: 'inpainting' },
{ key: 'other', value: 'other' },
{ key: 'styleTransfer', value: 'style-transfer' },
{ key: 'text', value: 'text' },
{ key: 'upscaling', value: 'upscaling' },
{ key: 'videoGeneration', value: 'video-generation' }
] as const
const TAG_SUGGESTIONS = [
'stable-diffusion',
'flux',
'sdxl',
'sd1.5',
'img2img',
'txt2img',
'upscale',
'face-restore',
'animation',
'video',
'lora',
'controlnet',
'ipadapter',
'inpainting',
'outpainting',
'depth',
'pose',
'segmentation',
'latent',
'sampler'
]
const tagQuery = ref('')
const showSuggestions = ref(false)
const filteredSuggestions = computed(() => {
const query = tagQuery.value.toLowerCase().trim()
if (!query) return []
const existing = ctx.template.value.tags ?? []
return TAG_SUGGESTIONS.filter(
(s) => s.includes(query) && !existing.includes(s)
)
})
function toggleCategory(value: string) {
const categories = ctx.template.value.categories ?? []
const index = categories.indexOf(value)
if (index >= 0) {
categories.splice(index, 1)
} else {
categories.push(value)
}
ctx.template.value.categories = [...categories]
}
function addTag(tag: string) {
const trimmed = tag.trim().toLowerCase()
if (!trimmed) return
const tags = ctx.template.value.tags ?? []
if (!tags.includes(trimmed)) {
ctx.template.value.tags = [...tags, trimmed]
}
tagQuery.value = ''
showSuggestions.value = false
}
function removeTag(tag: string) {
const tags = ctx.template.value.tags ?? []
ctx.template.value.tags = tags.filter((t) => t !== tag)
}
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -1,13 +0,0 @@
<template>
<div class="flex flex-col gap-6 p-6">
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.complete.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -1,111 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingDescription from './StepTemplatePublishingDescription.vue'
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
description: {
title: 'Description',
description: 'Write a detailed description of your template',
editorLabel: 'Description (Markdown)',
previewLabel: 'Description (Render preview)'
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(3)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingDescription, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingDescription', () => {
it('renders editor and preview labels', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Description (Markdown)')
expect(wrapper.text()).toContain('Description (Render preview)')
})
it('renders a textarea for markdown editing', () => {
const { wrapper } = mountStep()
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
})
it('binds textarea to template.description', () => {
const ctx = createContext({ description: 'Hello **world**' })
const { wrapper } = mountStep(ctx)
const textarea = wrapper.find('textarea')
expect((textarea.element as HTMLTextAreaElement).value).toBe(
'Hello **world**'
)
})
it('updates template.description when textarea changes', async () => {
const ctx = createContext({ description: '' })
const { wrapper } = mountStep(ctx)
const textarea = wrapper.find('textarea')
await textarea.setValue('New content')
expect(ctx.template.value.description).toBe('New content')
})
it('renders markdown preview from template.description', () => {
const ctx = createContext({ description: 'Some markdown' })
const { wrapper } = mountStep(ctx)
const preview = wrapper.find('[class*="prose"]')
expect(preview.html()).toContain('<p>Some markdown</p>')
})
it('renders empty preview when description is undefined', () => {
const ctx = createContext({})
const { wrapper } = mountStep(ctx)
const preview = wrapper.find('[class*="prose"]')
expect(preview.html()).toContain('<p></p>')
})
})

View File

@@ -1,47 +0,0 @@
<template>
<div class="flex h-full flex-row gap-4 p-6">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<label for="tpl-description-editor" class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.description.editorLabel') }}
</label>
<textarea
id="tpl-description-editor"
v-model="ctx.template.value.description"
class="min-h-0 flex-1 resize-none rounded-lg border border-border-default bg-secondary-background p-3 font-mono text-sm text-base-foreground focus:outline-none"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.description.previewLabel') }}
</span>
<div
class="prose prose-invert min-h-0 flex-1 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
v-html="renderedHtml"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const renderedHtml = computed(() =>
renderMarkdownToHtml(ctx.template.value.description ?? '')
)
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -1,22 +0,0 @@
<template>
<div class="flex flex-col gap-6 p-6">
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.landing.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
'update:valid': [valid: boolean]
}>()
onMounted(() => {
emit('update:valid', true)
})
</script>

View File

@@ -1,299 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import { NodeSourceType } from '@/types/nodeSource'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingMetadata from './StepTemplatePublishingMetadata.vue'
const mockNodes = vi.hoisted(() => [
{ type: 'KSampler', isSubgraphNode: () => false },
{ type: 'MyCustomNode', isSubgraphNode: () => false },
{ type: 'AnotherCustom', isSubgraphNode: () => false },
{ type: 'MyCustomNode', isSubgraphNode: () => false },
{ type: 'ExtraCustomPack', isSubgraphNode: () => false }
])
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
nodes: mockNodes
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
mapAllNodes: vi.fn(
(
graph: { nodes: Array<{ type: string }> },
mapFn: (node: { type: string }) => string | undefined
) => graph.nodes.map(mapFn).filter(Boolean)
)
}))
vi.mock('@/composables/useVramEstimation', () => ({
estimateWorkflowVram: vi.fn(() => 5_000_000_000)
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: {
KSampler: {
name: 'KSampler',
python_module: 'nodes',
nodeSource: { type: NodeSourceType.Core }
},
MyCustomNode: {
name: 'MyCustomNode',
python_module: 'custom_nodes.MyPack@1.0.nodes',
nodeSource: { type: NodeSourceType.CustomNodes }
},
AnotherCustom: {
name: 'AnotherCustom',
python_module: 'custom_nodes.MyPack@1.0.extra',
nodeSource: { type: NodeSourceType.CustomNodes }
},
ExtraCustomPack: {
name: 'ExtraCustomPack',
python_module: 'custom_nodes.ExtraPack.nodes',
nodeSource: { type: NodeSourceType.CustomNodes }
},
UnusedCustomNode: {
name: 'UnusedCustomNode',
python_module: 'custom_nodes.UnusedPack@2.0.nodes',
nodeSource: { type: NodeSourceType.CustomNodes }
}
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
metadata: {
title: 'Metadata',
description: 'Title, description, and author info',
titleLabel: 'Title',
difficultyLabel: 'Difficulty',
licenseLabel: 'License',
requiredNodesLabel: 'Custom Nodes',
requiredNodesDetected: 'Detected from workflow',
requiredNodesManualPlaceholder: 'Add custom node name…',
requiredNodesManualLabel: 'Additional custom nodes',
vramLabel: 'Estimated VRAM Requirement',
vramAutoDetected: 'Auto-detected from workflow:',
vramManualOverride: 'Manual override (GB):',
difficulty: {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced'
},
license: {
mit: 'MIT',
ccBy: 'CC BY',
ccBySa: 'CC BY-SA',
ccByNc: 'CC BY-NC',
apache: 'Apache',
custom: 'Custom'
}
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(2)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingMetadata, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context },
stubs: {
FormItem: {
template:
'<div :data-testid="`form-item-${id}`"><input :value="formValue" @input="$emit(\'update:formValue\', $event.target.value)" /></div>',
props: ['item', 'id', 'formValue', 'labelClass'],
emits: ['update:formValue']
}
}
}
}),
ctx: context
}
}
describe('StepTemplatePublishingMetadata', () => {
it('renders all form fields', () => {
const { wrapper } = mountStep()
expect(wrapper.find('#tpl-title').exists()).toBe(true)
expect(wrapper.text()).toContain('Difficulty')
expect(wrapper.find('[data-testid="form-item-tpl-license"]').exists()).toBe(
true
)
})
it('selects difficulty when radio button is clicked', async () => {
const ctx = createContext({})
const { wrapper } = mountStep(ctx)
const intermediateRadio = wrapper.find('#tpl-difficulty-intermediate')
await intermediateRadio.setValue(true)
expect(ctx.template.value.difficulty).toBe('intermediate')
})
it('displays detected custom nodes from the workflow', async () => {
const { wrapper } = mountStep()
await nextTick()
expect(wrapper.text()).toContain('AnotherCustom')
expect(wrapper.text()).toContain('MyCustomNode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('populates requiredNodes on mount when empty', () => {
const ctx = createContext({ requiredNodes: [] })
mountStep(ctx)
expect(ctx.template.value.requiredNodes).toContain('AnotherCustom')
expect(ctx.template.value.requiredNodes).toContain('MyCustomNode')
expect(ctx.template.value.requiredNodes).not.toContain('KSampler')
})
it('does not overwrite existing requiredNodes on mount', () => {
const ctx = createContext({ requiredNodes: ['PreExisting'] })
mountStep(ctx)
expect(ctx.template.value.requiredNodes).toEqual(['PreExisting'])
})
it('populates requiresCustomNodes with deduplicated package IDs on mount', () => {
const ctx = createContext({})
mountStep(ctx)
// MyCustomNode and AnotherCustom both come from MyPack@1.0 (@ stripped)
// ExtraCustomPack comes from ExtraPack (no @version in module path)
expect(ctx.template.value.requiresCustomNodes).toEqual([
'ExtraPack',
'MyPack'
])
})
it('does not overwrite existing requiresCustomNodes on mount', () => {
const ctx = createContext({ requiresCustomNodes: ['PreExisting'] })
mountStep(ctx)
expect(ctx.template.value.requiresCustomNodes).toEqual(['PreExisting'])
})
it('adds a manual custom node via the input', async () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.setValue('ManualNode')
await input.trigger('keydown.enter')
expect(ctx.template.value.requiredNodes).toContain('ManualNode')
})
it('removes a manual custom node when its remove button is clicked', async () => {
const ctx = createContext({
requiredNodes: ['AnotherCustom', 'MyCustomNode', 'ManualNode']
})
const { wrapper } = mountStep(ctx)
const removeButtons = wrapper.findAll(
'button[aria-label="Remove ManualNode"]'
)
await removeButtons[0].trigger('click')
expect(ctx.template.value.requiredNodes).not.toContain('ManualNode')
})
it('shows filtered custom node suggestions when typing', async () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Unused')
const suggestions = wrapper.findAll('.relative ul li')
expect(suggestions.length).toBe(1)
expect(suggestions[0].text()).toBe('UnusedCustomNode')
})
it('excludes already-added nodes from suggestions', async () => {
const ctx = createContext({ requiredNodes: ['UnusedCustomNode'] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Unused')
const suggestions = wrapper.findAll('.relative ul li')
expect(suggestions.length).toBe(0)
})
it('adds a node from the suggestion dropdown', async () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Unused')
const suggestion = wrapper.find('.relative ul li')
await suggestion.trigger('mousedown')
expect(ctx.template.value.requiredNodes).toContain('UnusedCustomNode')
})
})

View File

@@ -1,384 +0,0 @@
<template>
<div class="flex flex-col gap-6 p-6">
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-title-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.titleLabel') }}
</span>
</div>
<input
id="tpl-title"
v-model="ctx.template.value.title"
type="text"
class="h-8 w-[100em] rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
aria-labelledby="tpl-title-label"
/>
</div>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-difficulty-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.difficultyLabel') }}
</span>
</div>
<div
class="flex flex-row gap-4"
role="radiogroup"
aria-labelledby="tpl-difficulty-label"
>
<label
v-for="option in DIFFICULTY_OPTIONS"
:key="option.value"
:for="`tpl-difficulty-${option.value}`"
class="flex cursor-pointer items-center gap-1.5 text-sm"
>
<input
:id="`tpl-difficulty-${option.value}`"
type="radio"
name="tpl-difficulty"
:value="option.value"
:checked="ctx.template.value.difficulty === option.value"
:class="
cn(
'h-5 w-5 appearance-none rounded-full border-2 checked:bg-current checked:shadow-[inset_0_0_0_1px_white]',
option.borderClass
)
"
@change="ctx.template.value.difficulty = option.value"
/>
{{ option.text }}
</label>
</div>
</div>
<FormItem
id="tpl-license"
v-model:form-value="ctx.template.value.license"
:item="licenseField"
/>
<div class="flex flex-col gap-2">
<span id="tpl-required-nodes-label" class="text-sm text-muted">
{{ t('templatePublishing.steps.metadata.requiredNodesLabel') }}
</span>
<div
v-if="detectedCustomNodes.length > 0"
aria-labelledby="tpl-required-nodes-label"
>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.requiredNodesDetected') }}
</span>
<ul class="mt-1 flex flex-col gap-1">
<li
v-for="nodeName in detectedCustomNodes"
:key="nodeName"
class="flex items-center gap-2 rounded bg-secondary-background px-2 py-1 text-sm"
>
<i
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
/>
{{ nodeName }}
</li>
</ul>
</div>
<div>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.requiredNodesManualLabel') }}
</span>
<div class="relative mt-1">
<input
v-model="manualNodeQuery"
type="text"
class="h-8 w-56 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
:placeholder="
t(
'templatePublishing.steps.metadata.requiredNodesManualPlaceholder'
)
"
@focus="showNodeSuggestions = true"
@keydown.enter.prevent="addManualNode(manualNodeQuery)"
/>
<ul
v-if="showNodeSuggestions && filteredNodeSuggestions.length > 0"
class="absolute z-10 mt-1 max-h-40 w-56 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
>
<li
v-for="suggestion in filteredNodeSuggestions"
:key="suggestion"
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
@mousedown.prevent="addManualNode(suggestion)"
>
{{ suggestion }}
</li>
</ul>
</div>
<div
v-if="manualNodes.length > 0"
class="mt-1 flex flex-wrap items-center gap-1"
>
<span
v-for="node in manualNodes"
:key="node"
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ node }}
<button
type="button"
class="hover:text-danger"
:aria-label="`Remove ${node}`"
@click="removeManualNode(node)"
>
<i class="icon-[lucide--x] h-3 w-3" />
</button>
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<span id="tpl-vram-label" class="text-sm text-muted">
{{ t('templatePublishing.steps.metadata.vramLabel') }}
</span>
<div class="flex items-center gap-3">
<i class="icon-[lucide--cpu] h-3.5 w-3.5 text-muted-foreground" />
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.vramAutoDetected') }}
</span>
<span class="text-sm font-medium">
{{ formatSize(autoDetectedVram) }}
</span>
</div>
<div class="flex items-center gap-2">
<input
id="tpl-vram-override"
v-model.number="manualVramGb"
type="number"
min="0"
step="0.5"
class="h-8 w-24 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
aria-labelledby="tpl-vram-label"
/>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.vramManualOverride') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { formatSize } from '@/utils/formatUtil'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
import type { FormItem as FormItemType } from '@/platform/settings/types'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const nodeDefStore = useNodeDefStore()
const DIFFICULTY_OPTIONS = [
{
text: t('templatePublishing.steps.metadata.difficulty.beginner'),
value: 'beginner' as const,
borderClass: 'border-green-400'
},
{
text: t('templatePublishing.steps.metadata.difficulty.intermediate'),
value: 'intermediate' as const,
borderClass: 'border-amber-400'
},
{
text: t('templatePublishing.steps.metadata.difficulty.advanced'),
value: 'advanced' as const,
borderClass: 'border-red-400'
}
]
const licenseField: FormItemType = {
name: t('templatePublishing.steps.metadata.licenseLabel'),
type: 'combo',
options: [
{ text: t('templatePublishing.steps.metadata.license.mit'), value: 'mit' },
{
text: t('templatePublishing.steps.metadata.license.ccBy'),
value: 'cc-by'
},
{
text: t('templatePublishing.steps.metadata.license.ccBySa'),
value: 'cc-by-sa'
},
{
text: t('templatePublishing.steps.metadata.license.ccByNc'),
value: 'cc-by-nc'
},
{
text: t('templatePublishing.steps.metadata.license.apache'),
value: 'apache'
},
{
text: t('templatePublishing.steps.metadata.license.custom'),
value: 'custom'
}
],
attrs: { filter: true }
}
/**
* Collects unique custom node type names from the current workflow graph.
* Excludes core, essentials, and blueprint nodes.
*/
function detectCustomNodes(): string[] {
if (!app.rootGraph) return []
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
const unique = new Set(nodeTypes)
return [...unique]
.filter((type) => {
const def = nodeDefStore.nodeDefsByName[type]
if (!def) return false
return def.nodeSource.type === NodeSourceType.CustomNodes
})
.sort()
}
/**
* Extracts the custom node package ID from a `python_module` string.
*
* Custom node modules follow the pattern
* `custom_nodes.PackageName@version.submodule`, so the package ID is the
* second dot-segment with the `@version` suffix stripped.
*
* @returns The package folder name, or `undefined` when the module does not
* match the expected pattern.
*/
function extractPackageId(pythonModule: string): string | undefined {
const segments = pythonModule.split('.')
if (segments[0] !== 'custom_nodes' || !segments[1]) return undefined
return segments[1].split('@')[0]
}
/**
* Collects unique custom node package IDs from the current workflow graph.
*/
function detectCustomNodePackages(): string[] {
if (!app.rootGraph) return []
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
const packages = new Set<string>()
for (const type of nodeTypes) {
const def = nodeDefStore.nodeDefsByName[type]
if (!def || def.nodeSource.type !== NodeSourceType.CustomNodes) continue
const pkgId = extractPackageId(def.python_module)
if (pkgId) packages.add(pkgId)
}
return [...packages].sort()
}
const detectedCustomNodes = ref<string[]>([])
const autoDetectedVram = ref(0)
const GB = 1_073_741_824
/**
* Manual VRAM override in GB. When set to a positive number, this
* value (converted to bytes) takes precedence over the auto-detected
* estimate for `vramRequirement`.
*/
const manualVramGb = computed({
get: () => {
const stored = ctx.template.value.vramRequirement
if (!stored || stored === autoDetectedVram.value) return undefined
return Math.round((stored / GB) * 10) / 10
},
set: (gb: number | undefined) => {
if (gb && gb > 0) {
ctx.template.value.vramRequirement = Math.round(gb * GB)
} else {
ctx.template.value.vramRequirement = autoDetectedVram.value
}
}
})
onMounted(() => {
detectedCustomNodes.value = detectCustomNodes()
const existing = ctx.template.value.requiredNodes ?? []
if (existing.length === 0) {
ctx.template.value.requiredNodes = [...detectedCustomNodes.value]
}
const existingPackages = ctx.template.value.requiresCustomNodes ?? []
if (existingPackages.length === 0) {
ctx.template.value.requiresCustomNodes = detectCustomNodePackages()
}
autoDetectedVram.value = estimateWorkflowVram(app.rootGraph)
if (!ctx.template.value.vramRequirement) {
ctx.template.value.vramRequirement = autoDetectedVram.value
}
})
const manualNodes = computed(() => {
const all = ctx.template.value.requiredNodes ?? []
const detected = new Set(detectedCustomNodes.value)
return all.filter((n) => !detected.has(n))
})
const manualNodeQuery = ref('')
const showNodeSuggestions = ref(false)
/** All installed custom node type names for searchable suggestions. */
const allCustomNodeNames = computed(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((def) => def.nodeSource.type === NodeSourceType.CustomNodes)
.map((def) => def.name)
.sort()
)
const filteredNodeSuggestions = computed(() => {
const query = manualNodeQuery.value.toLowerCase().trim()
if (!query) return []
const existing = new Set(ctx.template.value.requiredNodes ?? [])
return allCustomNodeNames.value.filter(
(name) => name.toLowerCase().includes(query) && !existing.has(name)
)
})
function addManualNode(name: string) {
const trimmed = name.trim()
if (!trimmed) return
const nodes = ctx.template.value.requiredNodes ?? []
if (!nodes.includes(trimmed)) {
ctx.template.value.requiredNodes = [...nodes, trimmed]
}
manualNodeQuery.value = ''
showNodeSuggestions.value = false
}
function removeManualNode(name: string) {
const nodes = ctx.template.value.requiredNodes ?? []
ctx.template.value.requiredNodes = nodes.filter((n) => n !== name)
}
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -1,288 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingPreview from './StepTemplatePublishingPreview.vue'
let blobCounter = 0
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
URL.revokeObjectURL = vi.fn()
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
metadata: {
titleLabel: 'Title',
difficultyLabel: 'Difficulty',
licenseLabel: 'License',
categoryLabel: 'Categories',
tagsLabel: 'Tags',
requiredNodesLabel: 'Custom Nodes',
difficulty: {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced'
},
license: {
mit: 'MIT',
ccBy: 'CC BY',
ccBySa: 'CC BY-SA',
ccByNc: 'CC BY-NC',
apache: 'Apache',
custom: 'Custom'
},
category: {
imageGeneration: 'Image Generation',
videoGeneration: 'Video Generation',
audio: 'Audio',
text: 'Text',
threeD: '3D',
upscaling: 'Upscaling',
inpainting: 'Inpainting',
controlNet: 'ControlNet',
styleTransfer: 'Style Transfer',
other: 'Other'
}
},
preview: {
sectionMetadata: 'Metadata',
sectionDescription: 'Description',
sectionPreviewAssets: 'Preview Assets',
sectionCategoriesAndTags: 'Categories & Tags',
thumbnailLabel: 'Thumbnail',
comparisonLabel: 'Before & After',
workflowPreviewLabel: 'Workflow Graph',
videoPreviewLabel: 'Video Preview',
galleryLabel: 'Gallery',
notProvided: 'Not provided',
noneDetected: 'None detected',
correct: 'Correct',
editStep: 'Edit'
},
previewGeneration: {
beforeImageLabel: 'Before',
afterImageLabel: 'After'
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(6)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingPreview, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingPreview', () => {
beforeEach(() => {
const assets = useTemplatePreviewAssets()
assets.clearAll()
vi.clearAllMocks()
blobCounter = 0
})
it('renders all section headings', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Metadata')
expect(wrapper.text()).toContain('Description')
expect(wrapper.text()).toContain('Preview Assets')
expect(wrapper.text()).toContain('Categories & Tags')
})
it('displays template title', () => {
const ctx = createContext({ title: 'My Workflow' })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('My Workflow')
})
it('displays difficulty level', () => {
const ctx = createContext({ difficulty: 'advanced' })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('Advanced')
})
it('displays license type', () => {
const ctx = createContext({ license: 'mit' })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('MIT')
})
it('displays required custom nodes', () => {
const ctx = createContext({
requiredNodes: ['ComfyUI-Impact-Pack', 'ComfyUI-Manager']
})
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('ComfyUI-Impact-Pack')
expect(wrapper.text()).toContain('ComfyUI-Manager')
})
it('shows "None detected" when no custom nodes', () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('None detected')
})
it('renders description as markdown HTML', () => {
const ctx = createContext({ description: 'Hello **bold**' })
const { wrapper } = mountStep(ctx)
const prose = wrapper.find('[class*="prose"]')
expect(prose.html()).toContain('<p>Hello **bold**</p>')
})
it('shows "Not provided" when description is empty', () => {
const ctx = createContext({})
const { wrapper } = mountStep(ctx)
const text = wrapper.text()
expect(text).toContain('Not provided')
})
it('displays categories as pills', () => {
const ctx = createContext({
categories: ['image-generation', 'controlnet']
})
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('Image Generation')
expect(wrapper.text()).toContain('ControlNet')
})
it('displays tags as pills', () => {
const ctx = createContext({ tags: ['flux', 'sdxl'] })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('flux')
expect(wrapper.text()).toContain('sdxl')
})
it('displays thumbnail when asset is cached', () => {
const assets = useTemplatePreviewAssets()
assets.setThumbnail(new File([''], 'thumb.png'))
const ctx = createContext({ thumbnail: 'blob:thumb' })
const { wrapper } = mountStep(ctx)
const imgs = wrapper.findAll('img')
const thumbImg = imgs.find((img) =>
img.attributes('alt')?.includes('thumb.png')
)
expect(thumbImg?.exists()).toBe(true)
})
it('displays gallery images when assets are cached', () => {
const assets = useTemplatePreviewAssets()
assets.addGalleryImage(new File([''], 'a.png'))
assets.addGalleryImage(new File([''], 'b.png'))
const ctx = createContext({
gallery: [
{ type: 'image', url: 'blob:a', caption: 'a.png' },
{ type: 'image', url: 'blob:b', caption: 'b.png' }
]
})
const { wrapper } = mountStep(ctx)
const imgs = wrapper.findAll('img')
const galleryImgs = imgs.filter(
(img) =>
img.attributes('alt') === 'a.png' || img.attributes('alt') === 'b.png'
)
expect(galleryImgs).toHaveLength(2)
})
it('renders a "Correct" button', () => {
const { wrapper } = mountStep()
const correctBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Correct'))
expect(correctBtn?.exists()).toBe(true)
})
it('calls nextStep when "Correct" button is clicked', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const correctBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Correct'))
await correctBtn!.trigger('click')
expect(ctx.nextStep).toHaveBeenCalled()
})
it('navigates to metadata step when edit is clicked on metadata section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[0].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(2)
})
it('navigates to description step when edit is clicked on description section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[1].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(3)
})
it('navigates to preview generation step when edit is clicked on assets section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[2].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(4)
})
it('navigates to category step when edit is clicked on categories section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[3].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(5)
})
})

View File

@@ -1,298 +0,0 @@
<!--
Step 6 of the template publishing wizard. Displays a read-only summary
of all user-provided data so the author can audit it before submission.
-->
<template>
<div class="flex flex-col gap-6 overflow-y-auto p-6">
<!-- Metadata -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionMetadata')"
@edit="ctx.goToStep(2)"
>
<PreviewField
:label="t('templatePublishing.steps.metadata.titleLabel')"
:value="tpl.title"
/>
<PreviewField
:label="t('templatePublishing.steps.metadata.difficultyLabel')"
:value="difficultyLabel"
/>
<PreviewField
:label="t('templatePublishing.steps.metadata.licenseLabel')"
:value="licenseLabel"
/>
<PreviewField
:label="t('templatePublishing.steps.metadata.requiredNodesLabel')"
>
<ul
v-if="(tpl.requiredNodes ?? []).length > 0"
class="flex flex-col gap-0.5"
>
<li
v-for="node in tpl.requiredNodes"
:key="node"
class="flex items-center gap-1.5 text-sm"
>
<i
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
/>
{{ node }}
</li>
</ul>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.noneDetected') }}
</span>
</PreviewField>
<PreviewField
:label="t('templatePublishing.steps.preview.vramLabel')"
:value="vramLabel"
/>
</PreviewSection>
<!-- Description -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionDescription')"
@edit="ctx.goToStep(3)"
>
<div
v-if="tpl.description"
class="prose prose-invert max-h-48 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
v-html="renderedDescription"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewSection>
<!-- Preview Assets -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionPreviewAssets')"
@edit="ctx.goToStep(4)"
>
<!-- Thumbnail -->
<PreviewField
:label="t('templatePublishing.steps.preview.thumbnailLabel')"
>
<img
v-if="assets.thumbnail.value"
:src="assets.thumbnail.value.objectUrl"
:alt="assets.thumbnail.value.originalName"
class="h-28 w-44 rounded-lg object-cover"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Before & After -->
<PreviewField
:label="t('templatePublishing.steps.preview.comparisonLabel')"
>
<div
v-if="assets.beforeImage.value || assets.afterImage.value"
class="flex gap-3"
>
<div v-if="assets.beforeImage.value" class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
}}
</span>
<img
:src="assets.beforeImage.value.objectUrl"
:alt="assets.beforeImage.value.originalName"
class="h-24 w-24 rounded-lg object-cover"
/>
</div>
<div v-if="assets.afterImage.value" class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.afterImageLabel')
}}
</span>
<img
:src="assets.afterImage.value.objectUrl"
:alt="assets.afterImage.value.originalName"
class="h-24 w-24 rounded-lg object-cover"
/>
</div>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Workflow Graph -->
<PreviewField
:label="t('templatePublishing.steps.preview.workflowPreviewLabel')"
>
<img
v-if="assets.workflowPreview.value"
:src="assets.workflowPreview.value.objectUrl"
:alt="assets.workflowPreview.value.originalName"
class="h-28 w-48 rounded-lg object-cover"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Video Preview -->
<PreviewField
:label="t('templatePublishing.steps.preview.videoPreviewLabel')"
>
<video
v-if="assets.videoPreview.value"
:src="assets.videoPreview.value.objectUrl"
controls
class="h-28 w-48 rounded-lg"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Gallery -->
<PreviewField :label="t('templatePublishing.steps.preview.galleryLabel')">
<div
v-if="assets.galleryImages.value.length > 0"
class="flex flex-wrap gap-2"
>
<img
v-for="(img, i) in assets.galleryImages.value"
:key="img.originalName + i"
:src="img.objectUrl"
:alt="img.originalName"
class="h-20 w-20 rounded-lg object-cover"
/>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
</PreviewSection>
<!-- Categories & Tags -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionCategoriesAndTags')"
@edit="ctx.goToStep(5)"
>
<PreviewField
:label="t('templatePublishing.steps.metadata.categoryLabel')"
>
<div
v-if="(tpl.categories ?? []).length > 0"
class="flex flex-wrap gap-1"
>
<span
v-for="cat in tpl.categories"
:key="cat"
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ categoryDisplayName(cat) }}
</span>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<PreviewField :label="t('templatePublishing.steps.metadata.tagsLabel')">
<div v-if="(tpl.tags ?? []).length > 0" class="flex flex-wrap gap-1">
<span
v-for="tag in tpl.tags"
:key="tag"
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ tag }}
</span>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
</PreviewSection>
<!-- Correct button -->
<div class="flex justify-end pt-2">
<Button size="lg" @click="ctx.nextStep()">
<i class="icon-[lucide--check] mr-1" />
{{ t('templatePublishing.steps.preview.correct') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { formatSize } from '@/utils/formatUtil'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LicenseType } from '@/types/templateMarketplace'
import Button from '@/components/ui/button/Button.vue'
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { PublishingStepperKey } from '../types'
import PreviewField from './preview/PreviewField.vue'
import PreviewSection from './preview/PreviewSection.vue'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const assets = useTemplatePreviewAssets()
const tpl = computed(() => ctx.template.value)
const renderedDescription = computed(() =>
renderMarkdownToHtml(tpl.value.description ?? '')
)
const CATEGORY_KEY_MAP: Record<string, string> = {
'3d': 'threeD',
audio: 'audio',
controlnet: 'controlNet',
'image-generation': 'imageGeneration',
inpainting: 'inpainting',
other: 'other',
'style-transfer': 'styleTransfer',
text: 'text',
upscaling: 'upscaling',
'video-generation': 'videoGeneration'
}
function categoryDisplayName(value: string): string {
const key = CATEGORY_KEY_MAP[value]
if (!key) return value
return t(`templatePublishing.steps.metadata.category.${key}`)
}
const LICENSE_KEY_MAP: Record<string, string> = {
mit: 'mit',
'cc-by': 'ccBy',
'cc-by-sa': 'ccBySa',
'cc-by-nc': 'ccByNc',
apache: 'apache',
custom: 'custom'
}
const licenseLabel = computed(() => {
const license = tpl.value.license
if (!license) return t('templatePublishing.steps.preview.notProvided')
const key = LICENSE_KEY_MAP[license as LicenseType]
if (!key) return license
return t(`templatePublishing.steps.metadata.license.${key}`)
})
const difficultyLabel = computed(() => {
const difficulty = tpl.value.difficulty
if (!difficulty) return t('templatePublishing.steps.preview.notProvided')
return t(`templatePublishing.steps.metadata.difficulty.${difficulty}`)
})
const vramLabel = computed(() => {
const vram = tpl.value.vramRequirement
if (!vram) return t('templatePublishing.steps.preview.notProvided')
return formatSize(vram)
})
</script>

View File

@@ -1,239 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingPreviewGeneration from './StepTemplatePublishingPreviewGeneration.vue'
let blobCounter = 0
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
URL.revokeObjectURL = vi.fn()
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
previewGeneration: {
thumbnailLabel: 'Thumbnail',
thumbnailHint: 'Primary image shown in marketplace listings',
comparisonLabel: 'Before & After Comparison',
comparisonHint: 'Show what the workflow transforms',
beforeImageLabel: 'Before',
afterImageLabel: 'After',
workflowPreviewLabel: 'Workflow Graph',
workflowPreviewHint: 'Screenshot of the workflow graph layout',
videoPreviewLabel: 'Video Preview',
videoPreviewHint: 'Optional short video demonstrating the workflow',
galleryLabel: 'Example Gallery',
galleryHint: 'Up to {max} example output images',
uploadPrompt: 'Click to upload',
removeFile: 'Remove'
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(4)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingPreviewGeneration, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingPreviewGeneration', () => {
beforeEach(() => {
const assets = useTemplatePreviewAssets()
assets.clearAll()
vi.clearAllMocks()
blobCounter = 0
})
it('renders all upload sections', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Thumbnail')
expect(wrapper.text()).toContain('Before & After Comparison')
expect(wrapper.text()).toContain('Workflow Graph')
expect(wrapper.text()).toContain('Video Preview')
expect(wrapper.text()).toContain('Example Gallery')
})
it('renders before and after upload zones side by side', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Before')
expect(wrapper.text()).toContain('After')
})
it('updates template thumbnail on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[0].vm.$emit('upload', new File([''], 'thumb.png'))
expect(ctx.template.value.thumbnail).toMatch(/^blob:/)
})
it('clears template thumbnail on remove', () => {
const assets = useTemplatePreviewAssets()
assets.setThumbnail(new File([''], 'thumb.png'))
const ctx = createContext({ thumbnail: 'blob:old' })
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[0].vm.$emit('remove')
expect(ctx.template.value.thumbnail).toBe('')
expect(assets.thumbnail.value).toBeNull()
})
it('updates template beforeImage on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[1].vm.$emit('upload', new File([''], 'before.png'))
expect(ctx.template.value.beforeImage).toMatch(/^blob:/)
})
it('updates template afterImage on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[2].vm.$emit('upload', new File([''], 'after.png'))
expect(ctx.template.value.afterImage).toMatch(/^blob:/)
})
it('updates template workflowPreview on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[3].vm.$emit('upload', new File([''], 'graph.png'))
expect(ctx.template.value.workflowPreview).toMatch(/^blob:/)
})
it('updates template videoPreview on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[4].vm.$emit(
'upload',
new File([''], 'demo.mp4', { type: 'video/mp4' })
)
expect(ctx.template.value.videoPreview).toMatch(/^blob:/)
})
it('shows the gallery add button when gallery is empty', () => {
const { wrapper } = mountStep()
const addButton = wrapper.find('[role="button"]')
expect(addButton.exists()).toBe(true)
})
it('adds gallery images to the template on upload', async () => {
const ctx = createContext({ gallery: [] })
const { wrapper } = mountStep(ctx)
const galleryInput = wrapper.find('input[multiple]')
const file = new File([''], 'output.png', { type: 'image/png' })
Object.defineProperty(galleryInput.element, 'files', { value: [file] })
await galleryInput.trigger('change')
expect(ctx.template.value.gallery).toHaveLength(1)
expect(ctx.template.value.gallery![0].url).toMatch(/^blob:/)
expect(ctx.template.value.gallery![0].caption).toBe('output.png')
})
it('removes a gallery image by index', async () => {
const assets = useTemplatePreviewAssets()
assets.addGalleryImage(new File([''], 'a.png'))
assets.addGalleryImage(new File([''], 'b.png'))
const ctx = createContext({
gallery: [
{ type: 'image', url: 'blob:a', caption: 'a.png' },
{ type: 'image', url: 'blob:b', caption: 'b.png' }
]
})
const { wrapper } = mountStep(ctx)
const removeButtons = wrapper.findAll('button[aria-label="Remove"]')
await removeButtons[0].trigger('click')
expect(ctx.template.value.gallery).toHaveLength(1)
expect(ctx.template.value.gallery![0].caption).toBe('b.png')
})
})

View File

@@ -1,258 +0,0 @@
<!--
Step 4 of the template publishing wizard. Collects preview assets:
thumbnail, before/after comparison, workflow graph, optional video,
and an optional gallery of up to six example output images.
-->
<template>
<div class="flex flex-col gap-6 overflow-y-auto p-6">
<!-- Thumbnail -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.thumbnailLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.thumbnailHint') }}
</span>
<TemplateAssetUploadZone
:asset="assets.thumbnail.value"
size-class="h-40 w-64"
@upload="onThumbnailUpload"
@remove="onThumbnailRemove"
/>
</section>
<!-- Before & After Comparison -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.comparisonLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.comparisonHint') }}
</span>
<div class="flex flex-row gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
}}
</span>
<TemplateAssetUploadZone
:asset="assets.beforeImage.value"
@upload="onBeforeUpload"
@remove="onBeforeRemove"
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.afterImageLabel')
}}
</span>
<TemplateAssetUploadZone
:asset="assets.afterImage.value"
@upload="onAfterUpload"
@remove="onAfterRemove"
/>
</div>
</div>
</section>
<!-- Workflow Graph Preview -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{
t('templatePublishing.steps.previewGeneration.workflowPreviewLabel')
}}
</span>
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.workflowPreviewHint')
}}
</span>
<TemplateAssetUploadZone
:asset="assets.workflowPreview.value"
size-class="h-40 w-72"
@upload="onWorkflowUpload"
@remove="onWorkflowRemove"
/>
</section>
<!-- Video Preview (optional) -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.videoPreviewLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.videoPreviewHint') }}
</span>
<TemplateAssetUploadZone
:asset="assets.videoPreview.value"
accept="video/*"
preview-type="video"
size-class="h-40 w-72"
@upload="onVideoUpload"
@remove="onVideoRemove"
/>
</section>
<!-- Example Output Gallery -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.galleryLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.galleryHint', {
max: MAX_GALLERY_IMAGES
})
}}
</span>
<div class="flex flex-wrap gap-3">
<div
v-for="(asset, index) in assets.galleryImages.value"
:key="asset.originalName + index"
class="group relative h-28 w-28 overflow-hidden rounded-lg"
>
<img
:src="asset.objectUrl"
:alt="asset.originalName"
class="h-full w-full object-cover"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-1.5 py-0.5 opacity-0 transition-opacity group-hover:opacity-100"
>
<span class="truncate text-[10px] text-white">
{{ asset.originalName }}
</span>
<button
type="button"
class="shrink-0 text-white hover:text-danger"
:aria-label="
t('templatePublishing.steps.previewGeneration.removeFile')
"
@click="onGalleryRemove(index)"
>
<i class="icon-[lucide--x] h-3.5 w-3.5" />
</button>
</div>
</div>
<div
v-if="assets.galleryImages.value.length < MAX_GALLERY_IMAGES"
class="flex h-28 w-28 cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground"
role="button"
:tabindex="0"
:aria-label="
t('templatePublishing.steps.previewGeneration.uploadPrompt')
"
@click="galleryInput?.click()"
@keydown.enter="galleryInput?.click()"
>
<i class="icon-[lucide--plus] h-5 w-5 text-muted-foreground" />
</div>
</div>
<input
ref="galleryInput"
type="file"
accept="image/*"
multiple
class="hidden"
@change="onGallerySelect"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { inject, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import {
MAX_GALLERY_IMAGES,
useTemplatePreviewAssets
} from '@/composables/useTemplatePreviewAssets'
import { PublishingStepperKey } from '../types'
import TemplateAssetUploadZone from '../TemplateAssetUploadZone.vue'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const assets = useTemplatePreviewAssets()
const galleryInput = ref<HTMLInputElement | null>(null)
function onThumbnailUpload(file: File) {
ctx.template.value.thumbnail = assets.setThumbnail(file)
}
function onThumbnailRemove() {
assets.clearThumbnail()
ctx.template.value.thumbnail = ''
}
function onBeforeUpload(file: File) {
ctx.template.value.beforeImage = assets.setBeforeImage(file)
}
function onBeforeRemove() {
assets.clearBeforeImage()
ctx.template.value.beforeImage = undefined
}
function onAfterUpload(file: File) {
ctx.template.value.afterImage = assets.setAfterImage(file)
}
function onAfterRemove() {
assets.clearAfterImage()
ctx.template.value.afterImage = undefined
}
function onWorkflowUpload(file: File) {
ctx.template.value.workflowPreview = assets.setWorkflowPreview(file)
}
function onWorkflowRemove() {
assets.clearWorkflowPreview()
ctx.template.value.workflowPreview = ''
}
function onVideoUpload(file: File) {
ctx.template.value.videoPreview = assets.setVideoPreview(file)
}
function onVideoRemove() {
assets.clearVideoPreview()
ctx.template.value.videoPreview = undefined
}
function onGallerySelect(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files) return
for (const file of files) {
const url = assets.addGalleryImage(file)
if (url) {
const gallery = ctx.template.value.gallery ?? []
ctx.template.value.gallery = [
...gallery,
{ type: 'image', url, caption: file.name }
]
}
}
input.value = ''
}
function onGalleryRemove(index: number) {
assets.removeGalleryImage(index)
const gallery = ctx.template.value.gallery ?? []
ctx.template.value.gallery = gallery.filter((_, i) => i !== index)
}
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -1,84 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingSubmissionForReview from './StepTemplatePublishingSubmissionForReview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
submit: 'Submit for Review',
steps: {
submissionForReview: {
title: 'Submit',
description: 'Submit your template for review.'
}
}
}
}
}
})
function createContext(
overrides: Partial<PublishingStepperContext> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>({})
const currentStep = ref(7)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn(),
...overrides
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingSubmissionForReview, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingSubmissionForReview', () => {
it('renders the description text', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Submit your template for review.')
})
it('renders a submit button', () => {
const { wrapper } = mountStep()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(button.text()).toBe('Submit for Review')
})
it('calls nextStep when the submit button is clicked', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const button = wrapper.find('button')
await button.trigger('click')
expect(ctx.nextStep).toHaveBeenCalledOnce()
})
})

View File

@@ -1,23 +0,0 @@
<template>
<div class="flex flex-col gap-6 p-6">
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.submissionForReview.description') }}
</p>
<div class="flex justify-end">
<Button size="lg" @click="stepper.nextStep()">
{{ t('templatePublishing.submit') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { PublishingStepperKey } from '@/components/templatePublishing/types'
const { t } = useI18n()
const stepper = inject(PublishingStepperKey)!
</script>

View File

@@ -1,28 +0,0 @@
<!--
A labeled field within a preview section. Shows a label on the left
and either the value text or a default slot on the right.
-->
<template>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
<div class="text-sm">
<slot>
<span v-if="value">{{ value }}</span>
<span v-else class="text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps<{
label: string
value?: string
}>()
const { t } = useI18n()
</script>

View File

@@ -1,38 +0,0 @@
<!--
A collapsible section in the preview step, showing a heading with an
"Edit" button that navigates back to the originating step.
-->
<template>
<section class="flex flex-col gap-3">
<div
class="flex items-center justify-between border-b border-border-default pb-1"
>
<h3 class="text-sm font-semibold text-muted">{{ label }}</h3>
<button
type="button"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-base-foreground"
@click="emit('edit')"
>
<i class="icon-[lucide--pencil] h-3 w-3" />
{{ t('templatePublishing.steps.preview.editStep') }}
</button>
</div>
<div class="flex flex-col gap-3 pl-1">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps<{
label: string
}>()
const emit = defineEmits<{
edit: []
}>()
const { t } = useI18n()
</script>

View File

@@ -1,83 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
/**
* Definition of a single step in the template publishing wizard.
*/
export interface PublishingStepDefinition {
/** 1-indexed step number */
number: number
/** i18n key for the step's display title */
titleKey: string
/** i18n key for the step's short description */
descriptionKey: string
}
/**
* Context shared between the publishing dialog and its step panels
* via provide/inject.
*/
export interface PublishingStepperContext {
currentStep: Readonly<Ref<number>>
totalSteps: number
isFirstStep: Readonly<Ref<boolean>>
isLastStep: Readonly<Ref<boolean>>
canProceed: Readonly<Ref<boolean>>
template: Ref<Partial<MarketplaceTemplate>>
nextStep: () => void
prevStep: () => void
goToStep: (step: number) => void
saveDraft: () => void
setStepValid: (step: number, valid: boolean) => void
}
/**
* Injection key for the publishing stepper context, allowing step panel
* components to access shared navigation and draft state.
*/
export const PublishingStepperKey: InjectionKey<PublishingStepperContext> =
Symbol('PublishingStepperContext')
export const PUBLISHING_STEP_DEFINITIONS: PublishingStepDefinition[] = [
{
number: 1,
titleKey: 'templatePublishing.steps.landing.title',
descriptionKey: 'templatePublishing.steps.landing.description'
},
{
number: 2,
titleKey: 'templatePublishing.steps.metadata.title',
descriptionKey: 'templatePublishing.steps.metadata.description'
},
{
number: 3,
titleKey: 'templatePublishing.steps.description.title',
descriptionKey: 'templatePublishing.steps.description.description'
},
{
number: 4,
titleKey: 'templatePublishing.steps.previewGeneration.title',
descriptionKey: 'templatePublishing.steps.previewGeneration.description'
},
{
number: 5,
titleKey: 'templatePublishing.steps.categoryAndTagging.title',
descriptionKey: 'templatePublishing.steps.categoryAndTagging.description'
},
{
number: 6,
titleKey: 'templatePublishing.steps.preview.title',
descriptionKey: 'templatePublishing.steps.preview.description'
},
{
number: 7,
titleKey: 'templatePublishing.steps.submissionForReview.title',
descriptionKey: 'templatePublishing.steps.submissionForReview.description'
},
{
number: 8,
titleKey: 'templatePublishing.steps.complete.title',
descriptionKey: 'templatePublishing.steps.complete.description'
}
]

View File

@@ -16,20 +16,17 @@ import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
import TopbarBadge from './TopbarBadge.vue'
withDefaults(
defineProps<{
displayMode?: 'full' | 'compact' | 'icon-only'
reverseOrder?: boolean
noPadding?: boolean
backgroundColor?: string
}>(),
{
displayMode: 'full',
reverseOrder: false,
noPadding: false,
backgroundColor: 'var(--comfy-menu-bg)'
}
)
const {
displayMode = 'full',
reverseOrder = false,
noPadding = false,
backgroundColor = 'var(--comfy-menu-bg)'
} = defineProps<{
displayMode?: 'full' | 'compact' | 'icon-only'
reverseOrder?: boolean
noPadding?: boolean
backgroundColor?: string
}>()
const { t } = useI18n()

View File

@@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
// Mock the useSubscriptionDialog composable
const mockSubscriptionDialogShow = vi.fn()
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: vi.fn(() => ({
show: mockSubscriptionDialogShow,
show: vi.fn(),
showPricingTable: mockShowPricingTable,
hide: vi.fn()
}))
})
@@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => {
await plansPricingItem.trigger('click')
// Verify subscription dialog show was called
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
// Verify showPricingTable was called
expect(mockShowPricingTable).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()

Some files were not shown because too many files have changed in this diff Show More