Compare commits

...

94 Commits

Author SHA1 Message Date
Johnpaul
dd9680bac8 fix: remove VRAM sorting option from template filtering 2025-09-26 18:53:25 +01:00
Jin Yi
f44356b152 fix: workflow template style review 2025-09-26 15:20:51 +09:00
Johnpaul
a5100ba37a fix: add key for template list re-rendering on sorting changes 2025-09-25 02:41:45 +01:00
Johnpaul Chiwetelu
939254b1f2 Merge branch 'main' into feat/new-workflow-templates 2025-09-24 20:08:40 +01:00
Johnpaul
65bb4c695d fix: update 'Getting Started' label to reflect essential category title 2025-09-24 19:39:56 +01:00
Johnpaul
a3adaf2998 fix: remove unused SquareChip templates from WorkflowTemplateSelectorDialog 2025-09-24 19:39:56 +01:00
github-actions
77bb1e4928 Update test expectations [skip ci] 2025-09-24 05:35:08 +00:00
Johnpaul
7676ebfed0 fix e2e tests 2025-09-24 06:07:56 +01:00
Johnpaul
e43a2fcc3e fix: add flex-wrap to bottom-right slot for stacking square chips 2025-09-23 20:28:07 +01:00
Johnpaul
2cf342197a fix: improve template loading feedback with better placement for title display 2025-09-23 06:37:18 +01:00
Johnpaul
920f6cfa91 fix: update template filtering and workflow templates to include 'Getting Started' category 2025-09-23 06:35:41 +01:00
Johnpaul
b6b4a562b9 fix: refactor containerClasses and containerStyle to use cn() and prevent undefined behaviour 2025-09-23 06:30:07 +01:00
Johnpaul
27e4ee5bd5 fix: update MultiSelect component to use VueUse's useFuse for improved search performance and reactivity 2025-09-23 05:44:12 +01:00
Johnpaul
91eb417eba fix: enhance loading states with skeletons and async state 2025-09-23 04:19:26 +01:00
Johnpaul
e06f967803 fix: implement debounced search query in useTemplateFiltering for improved performance 2025-09-23 03:57:46 +01:00
Johnpaul
d1e8ec3373 fix: update placeholder text in SearchBox to use translation function 2025-09-23 03:57:18 +01:00
Johnpaul
4a784dfbcb fix: optimize template hover state with v-memo and release DOM refs on unmount 2025-09-23 01:10:11 +01:00
Johnpaul
430549767f refactor: simplify hover state management in WorkflowTemplateSelectorDialog 2025-09-23 00:41:50 +01:00
Johnpaul
c7b4ec815c fix: add overflow-y-scroll to LeftSidePanel for improved scrolling behavior 2025-09-22 22:56:51 +01:00
Johnpaul
7291255fa4 refactor: remove unused SMALL_MODEL_SIZE_LIMIT constant from templateWorkflows 2025-09-22 22:50:18 +01:00
Johnpaul
e72efade04 refactor: let categories on leftsidepanel be one to one with JSON 2025-09-22 22:48:04 +01:00
Johnpaul
699d4b4320 fix: add backgroundRepeat style to AudioThumbnail component 2025-09-22 22:47:26 +01:00
Johnpaul
6f135197a0 refactor: update template parameter types in WorkflowTemplateSelectorDialog 2025-09-22 22:46:53 +01:00
Johnpaul
b9cc103968 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-22 20:17:28 +01:00
Johnpaul
76308e7bf0 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-19 05:03:16 +01:00
Johnpaul
b48f91f925 feat: Add extension-specific filtering to template filters 2025-09-17 04:32:15 +01:00
Johnpaul
632f53ab45 feat: Enhance navigation with collapsible groups and improved NavTitle component 2025-09-17 04:24:46 +01:00
Johnpaul
d48ed607c7 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-17 03:27:15 +01:00
Johnpaul
9fae98e431 refactor: Update import paths for template workflows and workflow templates store 2025-09-17 03:16:49 +01:00
Johnpaul
a3ca397763 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-17 03:07:14 +01:00
Johnpaul
ccf2c5721d Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-17 03:07:00 +01:00
Christian Byrne
c9849935ac [test] Add component test for Vue button widget (#5468)
* add component test for button widget

* [move] relocate WidgetButton test to proper directory

Move test from src/ to tests-ui/ directory structure and update import path
to work from new location - addresses review comment about test organization

* [refactor] make widget name optional in mock factory

Add optional name parameter to createMockWidget function for more flexible
test setup - addresses @DrJKL's suggestion about optional parameters

* [refactor] use it.for for parameterized button tests

Replace forEach loops with it.for syntax for testing button severities
and variants - addresses @DrJKL's suggestion for better test structure

* [auto-fix] Apply ESLint and Prettier fixes

* Revert "[move] relocate WidgetButton test to proper directory"

This reverts commit e9f4d57334.

* [test] increase rapid clicks test from 5 to 16

Better test coverage for concurrent click handling

* name click number a shared variable

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-15 19:25:02 +01:00
Comfy Org PR Bot
5c64fcd604 [chore] Update electron-types to 0.4.72 (#5526)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-09-15 19:25:02 +01:00
filtered
cca69a8799 Fix update Action version extraction (#5527)
Was pulling frontend version instead.
2025-09-15 19:25:02 +01:00
snomiao
8cee668486 feat: add test count display to Playwright PR comments (#5458)
* feat: add test count display to Playwright PR comments

- Add extract-playwright-counts.mjs script to parse test results from Playwright reports
- Update pr-playwright-deploy-and-comment.sh to extract and display test counts
- Show overall summary with passed/failed/flaky/skipped counts
- Display per-browser test counts inline with report links
- Use dynamic status icons based on test results (//⚠️)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: include skipped test count in per-browser display

- Add skipped test extraction for individual browser reports
- Update per-browser display format to show all four counts:
  ( passed /  failed / ⚠️ flaky / ⏭️ skipped)
- Provides complete test result visibility at a glance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve test count extraction reliability in CI

- Use absolute paths for script and report directories
- Add debug logging to help diagnose extraction issues
- Move counts display after View Report link as requested
- Format: [View Report](url) •  passed /  failed / ⚠️ flaky / ⏭️ skipped

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: generate JSON reports alongside HTML for test count extraction

- Add JSON reporter to Playwright test runs
- Generate report.json alongside HTML reports
- Store JSON report in playwright-report directory
- This enables accurate test count extraction from CI artifacts

The HTML reports alone don't contain easily extractable test statistics
as they use a React app with dynamically loaded data. JSON reports
provide direct access to test counts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: correct JSON reporter syntax for Playwright tests

- Use proper syntax for JSON reporter with outputFile option
- Run separate commands for HTML and JSON report merging
- Specify output path directly in reporter configuration
- Ensures report.json is created in playwright-report directory

This fixes the "No such file or directory" error when trying to move
report.json file, as it wasn't being created in the first place.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "fix: correct JSON reporter syntax for Playwright tests"

This reverts commit 605d7cc1e2.

* fix: use correct Playwright reporter syntax with comma-separated list

- Use --reporter=html,json syntax (comma-separated, not space)
- Move test-results.json to playwright-report/report.json after generation
- Remove incorrect PLAYWRIGHT_JSON_OUTPUT_NAME env variable
- Add || true to prevent failure if JSON file doesn't exist

The JSON reporter outputs to test-results.json by default when using
the comma-separated reporter list syntax.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve test count extraction reliability in CI

- Use separate --reporter flags for list, html, and json
- Set PLAYWRIGHT_JSON_OUTPUT_NAME env var to specify JSON output path
- Run HTML and JSON report generation separately for merged reports
- Ensures report.json is created in playwright-report directory

The combined reporter syntax wasn't creating the JSON file properly.
Using separate reporter flags with env var ensures JSON is generated.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update scripts/cicd/pr-playwright-deploy-and-comment.sh

Co-authored-by: Alexander Brown <drjkl@comfy.org>

* refactor: convert extraction script to TypeScript and use tsx

- Convert extract-playwright-counts.mjs to TypeScript (.ts)
- Add proper TypeScript types for better type safety
- Use tsx for execution instead of node
- Auto-install tsx in CI if not available
- Better alignment with the TypeScript codebase

This provides better type safety and consistency with the rest of
the codebase while maintaining the same functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(pr-playwright-deploy-and-comment.sh): move tsx installation check to the beginning of the script for better organization and efficiency

* [auto-fix] Apply ESLint and Prettier fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-09-15 19:25:02 +01:00
Christian Byrne
c673ce9d27 [test] Add Vue Node header component test (#5457)
* add node header component test

* [refactor] use separate const declarations instead of mutable variable in test - addresses @DrJKL's code style suggestion

Replace mutable `let icon` with descriptive `const expandedIcon` and `const collapsedIcon`
variables for better code clarity and immutability in the chevron icon test.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [config] remove unnecessary vitest exclude patterns - addresses @DrJKL's configuration review

Remove redundant exclude patterns from vitest config as they are already covered by
vitest's default exclusions. Simplifies configuration while maintaining functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [config] remove unnecessary vitest exclude patterns - addresses @DrJKL's configuration review

Remove redundant exclude patterns from vitest config as they are already covered by
vitest's default exclusions. Simplifies configuration while maintaining functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 19:25:02 +01:00
Alexander Brown
3d5bbbeb31 a11y: Bigger click/touch target for Slider track (#5524)
* a11y: Bigger click/touch target for Slider track

* a11y: z-index fix and bigger thumb too.
2025-09-15 19:25:02 +01:00
Christian Byrne
066d292eea ADR: PrimeVue Fork Decision (#5230)
* ADR: Add PrimeVue fork decision record

Adds ADR-0003 documenting the decision to fork PrimeVue as a monorepo workspace package. Key rationale includes transform coordinate system conflicts and virtual canvas scroll interference that require component-level modifications.

* ADR: Reject PrimeVue fork decision

- Change status from Proposed to Rejected
- Document rationale: implementation complexity with dual monorepos,
  maintenance burden, alternative solutions available
- Add specific code citations and repository links
- Include alternative approach using shadcn/ui for selective replacement
2025-09-15 19:25:02 +01:00
Alexander Brown
a28a52c9c0 Component: Vue Widget Slider (new) (#5516)
* feat: Initial shadcn configuration

* component: Add Slider component from shadcn-vue

* deps: Add tw-animate-css

* component: Align slider with Figma styles

* component: Set the step value for the slider, update styles

* fix: update component tests to work with Array of values

* vite: Don't reload dev server for test changes

* component: Swap text for a number input kept in sync with the slider

* cleanup: Don't need the override if the input isn't type="number"

* test: add step size tests

* cleanup: Don't need cn for these

* css: Update token names to match new Figma Variables

* lint: Fix camelCase vs train-case in passthrough

* feat: If the value is deleted, revert to the slider state cc: @PabloWiedemann

* feat: Improve cursor styles, grabbable thumb, clickable track

* lint: temporarily disable some warnings

* feat: Grabbing while sliding (most of the time)
2025-09-15 19:25:02 +01:00
Alexander Brown
64058e8e55 Feat: Change the Run button / ActionBar to dock by default (#5519)
* Feat: Change the Run button / ActionBar to dock by default

@PabloWiedemann

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-15 19:25:02 +01:00
Benjamin Lu
8cb10b0487 fix(links): remove drag-end offset in straight/linear modes by honoring CENTER/NONE as no-offset (#5520)
- Add 'none' direction to path renderer
- Map CENTER/NONE to 'none' in adapter
- Keep start-end offset intentional; end follows cursor exactly
- Preserve spline behavior and arrow rendering

Verified with typecheck; no visual changes outside dragging behavior.
2025-09-15 19:25:02 +01:00
Johnpaul
578bb54437 fix: enhance CardContainer and MultiSelect components with improved props handling and filtering logic 2025-09-13 02:35:36 +01:00
Johnpaul
6844b77899 fix: update fallback behavior for loading core workflow templates to default to English 2025-09-13 02:35:36 +01:00
Johnpaul
f0c8a6d1ac fix: add data-testid attributes for template workflows in WorkflowTemplateSelectorDialog 2025-09-13 02:35:36 +01:00
Johnpaul Chiwetelu
b1b1312807 Merge branch 'main' into feat/new-workflow-templates 2025-09-12 20:57:51 +01:00
Johnpaul
1bd6fddf2d fix: update category icon mappings to use Lucide icon classes 2025-09-12 20:55:52 +01:00
Johnpaul
482dfcc0c7 fix: reset base components to use default props 2025-09-12 20:55:03 +01:00
Johnpaul
1864b1c9bb fix: Replace BaseWidgetLayout with BaseModalLayout and ensure hoveredTemplates fallback 2025-09-12 20:52:22 +01:00
Johnpaul
406a53a14e Merge branch 'main' into feat/new-workflow-templates 2025-09-12 20:25:18 +01:00
Johnpaul
619728ca2b Merge branch 'main' into feat/new-workflow-templates 2025-09-09 02:25:06 +01:00
Johnpaul
2a50793f16 refactor: update BaseThumbnail tests and improve template description handling 2025-09-09 02:24:16 +01:00
Johnpaul
54c7851dbe refactor: delete unused components 2025-09-09 02:23:50 +01:00
Johnpaul
a96a8f5a4e refactor: rename NewWorkflowTemplateSelectorDialog 2025-09-09 01:31:56 +01:00
Johnpaul
06f6b67ecb refactor: remove old WorkflowTemplateSelector component 2025-09-09 01:30:38 +01:00
Johnpaul
cb3a29623b refactor: remove unused getCategoryIconByTitle function and clean up categoryIcons utility 2025-09-09 01:24:19 +01:00
Johnpaul
deb3d942b8 refactor: replace dialogService method call with useWorkflowTemplateSelectorDialog hook 2025-09-09 01:17:10 +01:00
Johnpaul
180a8ea16c refactor: update title styling and enhance hover transition effects 2025-09-09 01:02:25 +01:00
Johnpaul
73af4d5d7a refactor: enhance MultiSelect component with search functionality and dynamic options filtering 2025-09-09 00:51:53 +01:00
Johnpaul
88aa77a4e3 refactor: add page title display and improve template card structure 2025-09-08 20:34:55 +01:00
Johnpaul
a883288a70 refactor: adjust sort options container position and update SingleSelect width 2025-09-08 19:01:08 +01:00
Johnpaul
521842f3b7 refactor: update default sort option to 'newest' 2025-09-08 18:54:21 +01:00
Johnpaul
e465d1f50d refactor: update NavItem font size from text-xs to text-sm 2025-09-08 17:49:17 +01:00
Johnpaul
967e506821 refactor: update button label and visibility, adjust CardBottom properties, and enhance AudioThumbnail styling 2025-09-08 17:47:51 +01:00
Johnpaul
c0eacb7e97 refactor: update SingleSelect label class and adjust NavItem icon size 2025-09-08 17:47:30 +01:00
Johnpaul
0e3da72267 refactor: adjust MultiSelect overlay height 2025-09-08 17:46:19 +01:00
Johnpaul
4d70fed532 Add props to CatdBottom and CardContainer 2025-09-08 17:45:03 +01:00
Johnpaul
f896686e9e refactor: simplify thumbnail source retrieval and enhance onLoadWorkflow function 2025-09-08 02:26:10 +01:00
Johnpaul
387c088285 refactor: move tutorial button to card bottom 2025-09-07 23:38:12 +01:00
Johnpaul
deb3f27c74 wip: reposition workflow title text 2025-09-06 00:02:31 +01:00
Johnpaul
956692ba16 WIP use new workflowtemplate selector to match design 2025-09-05 22:53:33 +01:00
Johnpaul
142179d63f feat: enable search box in MultiSelect and replace tag spans with SquareChip in TemplateWorkflowCard 2025-09-05 22:08:13 +01:00
Johnpaul
c6933a4fdf style: enhance MultiSelect component's overlay and list for better scrolling experience 2025-09-05 22:06:58 +01:00
Johnpaul
eb72d04f99 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-05 20:22:26 +01:00
Johnpaul
24af9763d2 fix: remove LoRA training filter and counts from workflow templates 2025-08-30 01:41:36 +01:00
Johnpaul
01827df5f0 Implement falback default template Image 2025-08-28 21:30:11 +01:00
Johnpaul
1705760d29 fix: remove performance filters and counts from workflow templates 2025-08-28 20:53:24 +01:00
Johnpaul
fde58abda3 feat: add tutorial button and corresponding tooltip in TemplateWorkflowCard 2025-08-28 20:46:36 +01:00
Johnpaul
a3f026563a feat: add LLM category support and corresponding icon in workflow templates 2025-08-28 19:15:52 +01:00
Johnpaul
4471c069fe fix: update icon in WorkflowTemplateSelector and improve tag display in TemplateWorkflowCard 2025-08-28 19:07:35 +01:00
Johnpaul
b243b46cb4 feat: implement model size sorting and enhance performance filtering logic 2025-08-28 02:10:59 +01:00
Johnpaul
fa317d7172 fix: adjust dialog content styling for improved layout management 2025-08-28 01:36:58 +01:00
Johnpaul
5d88200aae fix layout overflow issues on bigger screens 2025-08-28 01:00:00 +01:00
Johnpaul
f6dc62ab8f Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-08-27 22:53:16 +01:00
Johnpaul
d273c1f348 Merge branch 'main' into feat/new-workflow-templates 2025-08-27 22:30:57 +01:00
Johnpaul
c6d6de74d6 [skip ci] 2025-08-21 02:43:10 +01:00
Johnpaul
8fb225f1f6 feat: add new sorting options for template filtering by VRAM utilization and model size 2025-08-20 22:31:17 +01:00
Johnpaul
9ec2f69282 feat: add category icon mapping functions for IDs and titles 2025-08-20 21:11:19 +01:00
Johnpaul
18482ac385 feat: enhance navigation and template filtering with icon support and new category mappings 2025-08-20 21:10:39 +01:00
Johnpaul
989e4e50d3 feat: remove TemplateSearchBar component and enhance template filtering options with use cases and licenses 2025-08-20 19:39:37 +01:00
Johnpaul
ce30ea3417 feat: enhance MultiSelect and SearchBox components with improved filtering and search functionality 2025-08-20 19:38:53 +01:00
Johnpaul
8e36c347c2 feat: use old template workflow view in workflow selector 2025-08-20 18:04:40 +01:00
Johnpaul
248a4c8072 Merge branch 'main' into feat/new-workflow-templates 2025-08-20 17:39:06 +01:00
Johnpaul
d36c868a7e feat: Implement workflow template selector dialog and enhance template filtering options 2025-08-20 17:18:11 +01:00
Johnpaul
ac8b108f39 feat: Enhance template filtering with Fuse.js and add model selection 2025-08-20 17:17:13 +01:00
44 changed files with 1832 additions and 1357 deletions

View File

@@ -80,6 +80,12 @@ test.describe('Templates', () => {
// Load a template
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()
@@ -102,48 +108,72 @@ test.describe('Templates', () => {
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses title field as fallback when the key is not found in locales', async ({
test('Uses proper locale files for templates', async ({ comfyPage }) => {
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
// Load the templates dialog and wait for the French index file request
const requestPromise = comfyPage.page.waitForRequest(
'**/templates/index.fr.json'
)
await comfyPage.executeCommand('Comfy.BrowseTemplates')
const request = await requestPromise
// Verify French index was requested
expect(request.url()).toContain('templates/index.fr.json')
await expect(comfyPage.templates.content).toBeVisible()
})
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
// Set locale to a language that doesn't have a template file
await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Expect the title to be used as fallback for template cards
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
await expect(
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
comfyPage.templates.content.getByRole('heading', {
name: 'Image Generation'
})
).toBeVisible()
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({
@@ -153,25 +183,43 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for at least one template card to appear
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
timeout: 5000
})
const firstCard = comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Take snapshot of the template grid
const templateGrid = comfyPage.templates.content.locator('.grid').first()
// Get the template grid
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
// Check grid layout at desktop size (default)
const desktopGridClass = await templateGrid.getAttribute('class')
expect(desktopGridClass).toContain('grid')
expect(desktopGridClass).toContain(
'grid-cols-[repeat(auto-fill,minmax(16rem,1fr))]'
)
// Count visible cards at desktop size
const desktopCardCount = await comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.count()
expect(desktopCardCount).toBeGreaterThan(0)
// Check cards at mobile viewport size
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
// Grid should still be responsive at mobile size
const mobileGridClass = await templateGrid.getAttribute('class')
expect(mobileGridClass).toContain('grid')
// Check cards at tablet size
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
// Grid should still be responsive at tablet size
const tabletGridClass = await templateGrid.getAttribute('class')
expect(tabletGridClass).toContain('grid')
})
test('hover effects work on template cards', async ({ comfyPage }) => {
@@ -179,10 +227,13 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card
const firstCard = comfyPage.page.locator('.template-card').first()
// Get a template card using data-testid
const firstCard = comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Check initial state - card should have transition classes
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
@@ -257,21 +308,42 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify cards are visible with varying content lengths
// Wait for cards to load
await expect(
comfyPage.page.getByText('This is a short description.')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a medium length description')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a much longer description')
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
).toBeVisible({ timeout: 5000 })
// Take snapshot of a grid with specific cards
const templateGrid = comfyPage.templates.content
.locator('.grid:has-text("Short Description")')
.first()
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
)
await expect(shortDescCard).toBeVisible()
await expect(mediumDescCard).toBeVisible()
await expect(longDescCard).toBeVisible()
// Verify descriptions are visible and have line-clamp class
// The description is in a p tag with text-muted class
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
await expect(shortDesc).toContainText('short description')
await expect(mediumDesc).toContainText('medium length description')
await expect(longDesc).toContainText('much longer description')
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -123,6 +123,7 @@
"@tiptap/extension-table-row": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@vueuse/core": "^11.0.0",
"@vueuse/integrations": "^13.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",

87
pnpm-lock.yaml generated
View File

@@ -68,6 +68,9 @@ importers:
'@vueuse/core':
specifier: ^11.0.0
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
'@vueuse/integrations':
specifier: ^13.9.0
version: 13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -2895,18 +2898,73 @@ packages:
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@13.9.0':
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
peerDependencies:
async-validator: ^4
axios: ^1
change-case: ^5
drauu: ^0.4
focus-trap: ^7
fuse.js: ^7
idb-keyval: ^6
jwt-decode: ^4
nprogress: ^0.2
qrcode: ^1.5
sortablejs: ^1
universal-cookie: ^7 || ^8
vue: ^3.5.0
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
sortablejs:
optional: true
universal-cookie:
optional: true
'@vueuse/metadata@11.0.0':
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/shared@11.0.0':
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@vueuse/shared@13.9.0':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
vue: ^3.5.0
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
@@ -6323,6 +6381,9 @@ packages:
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.0.8:
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -8879,7 +8940,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.7
vue-component-type-helpers: 3.0.8
'@swc/helpers@0.5.17':
dependencies:
@@ -9662,10 +9723,28 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/core@13.9.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
vue: 3.5.13(typescript@5.9.2)
'@vueuse/integrations@13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@vueuse/core': 13.9.0(vue@3.5.13(typescript@5.9.2))
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
vue: 3.5.13(typescript@5.9.2)
optionalDependencies:
axios: 1.11.0
fuse.js: 7.0.0
'@vueuse/metadata@11.0.0': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/metadata@13.9.0': {}
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
@@ -9679,6 +9758,10 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/shared@13.9.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue: 3.5.13(typescript@5.9.2)
'@webgpu/types@0.1.51': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
@@ -13545,6 +13628,8 @@ snapshots:
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.0.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1416_62)">
<path d="M7.99998 10.6667V8.00004M7.99998 5.33337H8.00665M14.6666 8.00004C14.6666 11.6819 11.6819 14.6667 7.99998 14.6667C4.31808 14.6667 1.33331 11.6819 1.33331 8.00004C1.33331 4.31814 4.31808 1.33337 7.99998 1.33337C11.6819 1.33337 14.6666 4.31814 14.6666 8.00004Z" stroke="#171718" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1416_62">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -1,7 +1,19 @@
<template>
<div class="flex-1 w-full h-full">
<div :class="containerClasses">
<slot></slot>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { fullHeight = true } = defineProps<{
fullHeight?: boolean
}>()
const containerClasses = computed(() =>
cn('flex-1 w-full', fullHeight && 'h-full')
)
</script>

View File

@@ -8,15 +8,21 @@
<script setup lang="ts">
import { computed } from 'vue'
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'portrait' | 'tallPortrait'
const { ratio = 'square', type } = defineProps<{
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
type?: string
}>()
const containerClasses = computed(() => {
const baseClasses =
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
if (type === 'workflow-template-card') {
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
}
const ratioClasses = {
smallSquare: 'aspect-240/311',
square: 'aspect-256/308',
portrait: 'aspect-256/325',
tallPortrait: 'aspect-256/353'

View File

@@ -2,26 +2,40 @@
<div :class="topStyle">
<slot class="absolute top-0 left-0 w-full h-full"></slot>
<div class="absolute top-2 left-2 flex gap-2">
<div
v-if="slots['top-left']"
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
>
<slot name="top-left"></slot>
</div>
<div class="absolute top-2 right-2 flex gap-2">
<div
v-if="slots['top-right']"
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
>
<slot name="top-right"></slot>
</div>
<div class="absolute bottom-2 left-2 flex gap-2">
<div
v-if="slots['bottom-left']"
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
>
<slot name="bottom-left"></slot>
</div>
<div class="absolute bottom-2 right-2 flex gap-2">
<div
v-if="slots['bottom-right']"
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
>
<slot name="bottom-right"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
const slots = useSlots()
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'landscape'

View File

@@ -24,7 +24,13 @@
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
<img
src="/assets/images/default-template.png"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,750 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
</template>
<template #header-right-area>
<div class="flex gap-2">
<IconTextButton
v-if="filteredCount !== totalCount"
type="secondary"
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
@click="resetFilters"
>
<template #icon>
<i-lucide:filter-x />
</template>
</IconTextButton>
</div>
</template>
<template #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
v-model:search-query="modelSearchText"
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:cpu />
</template>
</MultiSelect>
<!-- Use Case Filter -->
<MultiSelect
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:target />
</template>
</MultiSelect>
<!-- License Filter -->
<MultiSelect
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:file-text />
</template>
</MultiSelect>
<!-- Sort Options -->
<div class="absolute right-5">
<SingleSelect
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
class="min-w-[270px]"
>
<template #icon>
<i-lucide:arrow-up-down />
</template>
</SingleSelect>
</div>
</div>
<div
v-if="!isLoading"
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
>
<span>
{{ pageTitle }}
</span>
</div>
</template>
<template #content>
<!-- No Results State (only show when loaded and no results) -->
<div
v-if="!isLoading && filteredTemplates.length === 0"
class="flex flex-col items-center justify-center h-64 text-neutral-500"
>
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
<p class="text-lg mb-2">
{{ $t('templateWorkflows.noResults', 'No templates found') }}
</p>
<p class="text-sm">
{{
$t(
'templateWorkflows.noResultsHint',
'Try adjusting your search or filters'
)
}}
</p>
</div>
<div v-else>
<!-- Title -->
<span
v-if="isLoading"
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></span>
<!-- Template Cards Grid -->
<div
:key="templateListKey"
:style="gridStyle"
data-testid="template-workflows-content"
>
<!-- Loading Skeletons (show while loading initial data) -->
<CardContainer
v-for="n in isLoading ? 12 : 0"
:key="`initial-skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></div>
</div>
</CardBottom>
</template>
</CardContainer>
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
:key="template.name"
ref="cardRefs"
v-memo="[template.name, hoveredTemplate === template.name]"
ratio="smallSquare"
type="workflow-template-card"
:data-testid="`template-workflow-${template.name}`"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
>
<template #top>
<CardTop ratio="square">
<template #default>
<!-- Template Thumbnail -->
<div
class="w-full h-full relative rounded-lg overflow-hidden"
>
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
</template>
<template
v-else-if="template.thumbnailVariant === 'compareSlider'"
>
<CompareSliderThumbnail
:base-image-src="getBaseThumbnailSrc(template)"
:overlay-image-src="getOverlayThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
>
<HoverDissolveThumbnail
:base-image-src="getBaseThumbnailSrc(template)"
:overlay-image-src="getOverlayThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
<DefaultThumbnail
:src="getBaseThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover' ? 16 : 5
"
/>
</template>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 w-12 h-12 m-auto"
/>
</div>
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
v-for="tag in template.tags"
:key="tag"
:label="tag"
/>
</template>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="flex flex-col gap-2 pt-3">
<h3
class="line-clamp-1 text-sm m-0"
:title="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
>
{{
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
}}
</h3>
<div class="flex justify-between gap-2">
<div class="flex-1">
<p
class="line-clamp-2 text-sm text-muted m-0"
:title="getTemplateDescription(template)"
>
{{ getTemplateDescription(template) }}
</p>
</div>
<div
v-if="template.tutorialUrl"
class="flex flex-col-reverse justify-center"
>
<IconButton
v-if="hoveredTemplate === template.name"
v-tooltip.bottom="$t('g.seeTutorial')"
v-bind="$attrs"
type="primary"
size="sm"
@click.stop="openTutorial(template)"
>
<i class="icon-[lucide--info] size-4" />
</IconButton>
</div>
</div>
</div>
</CardBottom>
</template>
</CardContainer>
<!-- Loading More Skeletons -->
<CardContainer
v-for="n in isLoadingMore ? 6 : 0"
:key="`skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></div>
</div>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<!-- Load More Trigger -->
<div
v-if="!isLoading && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center mt-4"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
</div>
</div>
<!-- Results Summary -->
<div
v-if="!isLoading"
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
>
{{
$t('templateWorkflows.resultsCount', {
count: filteredCount,
total: totalCount
})
}}
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose } = defineProps<{
onClose: () => void
}>()
provide(OnCloseKey, onClose)
// Workflow templates store and composable
const workflowTemplatesStore = useWorkflowTemplatesStore()
const {
loadTemplates,
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
}
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
}
// Open tutorial in new tab
const openTutorial = (template: TemplateInfo) => {
if (template.tutorialUrl) {
window.open(template.tutorialUrl, '_blank')
}
}
// Get navigation items from the store, with skeleton items while loading
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
// Show skeleton navigation items while loading
if (isLoading.value) {
return [
{
id: 'skeleton-all',
label: 'All Templates',
icon: 'icon-[lucide--layout-grid]'
},
{
id: 'skeleton-basics',
label: 'Basics',
icon: 'icon-[lucide--graduation-cap]'
},
{
title: 'Generation Type',
items: [
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
]
},
{
title: 'Closed Source Models',
items: [
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
]
}
]
}
return workflowTemplatesStore.navGroupedTemplates
})
const gridStyle = computed(() => createGridStyle())
// Get enhanced templates for better filtering
const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
return allTemplates.value
}
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
})
// Template filtering
const {
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
sortBy,
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
totalCount,
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
return selectedModels.value.map((model) => ({ name: model, value: model }))
},
set(value: { name: string; value: string }[]) {
selectedModels.value = value.map((item) => item.value)
}
})
const selectedUseCaseObjects = computed({
get() {
return selectedUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
},
set(value: { name: string; value: string }[]) {
selectedUseCases.value = value.map((item) => item.value)
}
})
const selectedLicenseObjects = computed({
get() {
return selectedLicenses.value.map((license) => ({
name: license,
value: license
}))
},
set(value: { name: string; value: string }[]) {
selectedLicenses.value = value.map((item) => item.value)
}
})
// Loading states
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
// Navigation
const selectedNavItem = ref<string | null>('all')
// Search text for model filter
const modelSearchText = ref<string>('')
// Filter options
const modelOptions = computed(() =>
availableModels.value.map((model) => ({
name: model,
value: model
}))
)
const useCaseOptions = computed(() =>
availableUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
)
const licenseOptions = computed(() =>
availableLicenses.value.map((license) => ({
name: license,
value: license
}))
)
// Filter labels
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
} else if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
}
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
})
const licenseFilterLabel = computed(() => {
if (selectedLicenseObjects.value.length === 0) {
return t('templateWorkflows.licenseFilter', 'License')
} else if (selectedLicenseObjects.value.length === 1) {
return selectedLicenseObjects.value[0].name
} else {
return t('templateWorkflows.licensesSelected', {
count: selectedLicenseObjects.value.length
})
}
})
// Sort options
const sortOptions = computed(() => [
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
{
name: t(
'templateWorkflows.sort.modelSizeLowToHigh',
'Model Size (Low to High)'
),
value: 'model-size-low-to-high'
},
{
name: t('templateWorkflows.sort.alphabetical', 'Alphabetical (A-Z)'),
value: 'alphabetical'
}
])
// Lazy pagination setup
const loadTrigger = ref<HTMLElement | null>(null)
const shouldUsePagination = computed(() => !searchQuery.value.trim())
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset: resetPagination
} = useLazyPagination(filteredTemplates, { itemsPerPage: 24 }) // Load 24 items per page
// Display templates (all when searching, paginated when not)
const displayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Set up intersection observer for lazy loading
useIntersectionObserver(loadTrigger, () => {
if (
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
})
// Reset pagination when filters change
watch(
[
searchQuery,
selectedNavItem,
sortBy,
selectedModels,
selectedUseCases,
selectedLicenses
],
() => {
resetPagination()
// Clear loading state and force re-render of template list
loadingTemplate.value = null
templateListKey.value++
}
)
// Methods
const onLoadWorkflow = async (template: any) => {
loadingTemplate.value = template.name
try {
await loadWorkflowTemplate(
template.name,
getEffectiveSourceModule(template)
)
onClose()
} finally {
loadingTemplate.value = null
}
}
const pageTitle = computed(() => {
const navItem = navItems.value.find((item) =>
'id' in item
? item.id === selectedNavItem.value
: item.items?.some((sub) => sub.id === selectedNavItem.value)
)
if (!navItem) {
return t('templateWorkflows.allTemplates', 'All Templates')
}
return 'id' in navItem
? navItem.label
: navItem.items?.find((i) => i.id === selectedNavItem.value)?.label ||
t('templateWorkflows.allTemplates', 'All Templates')
})
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run both operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
])
return true
},
false, // initial state
{
immediate: true // Start loading immediately
}
)
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
</script>
<style>
/* Ensure the workflow template selector dialog fits within provided dialog */
.workflow-template-selector-dialog.base-widget-layout {
width: 100% !important;
max-width: 1400px;
height: 100% !important;
aspect-ratio: auto !important;
}
@media (min-width: 1600px) {
.workflow-template-selector-dialog.base-widget-layout {
max-width: 1600px;
}
}
</style>

View File

@@ -9,7 +9,7 @@
-->
<MultiSelect
v-model="selectedItems"
v-bind="$attrs"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
@@ -105,10 +105,11 @@
</template>
<script setup lang="ts">
import { type UseFuseOptions, useFuse } from '@vueuse/integrations/useFuse'
import Button from 'primevue/button'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/input/SearchBox.vue'
@@ -158,7 +159,7 @@ const {
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery')
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
@@ -167,6 +168,40 @@ const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
includeScore: false
},
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]
})
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({

View File

@@ -1,9 +1,15 @@
<template>
<div :class="wrapperStyle">
<div :class="wrapperStyle" @click="focusInput">
<i-lucide:search :class="iconColorStyle" />
<InputText
ref="input"
v-model="searchQuery"
:placeholder="placeHolder || 'Search...'"
:aria-label="
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
:placeholder="
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
type="text"
unstyled
:class="inputStyle"
@@ -13,8 +19,9 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const {
@@ -29,6 +36,13 @@ const {
// defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
const input = ref<{ $el: HTMLElement } | null>()
const focusInput = () => {
if (input.value && input.value.$el) {
input.value.$el.focus()
}
}
const wrapperStyle = computed(() => {
const baseClasses = [
'relative flex w-full items-center gap-2',

View File

@@ -143,7 +143,7 @@ const pt = computed(() => ({
label: {
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 outline-hidden'
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
},
dropdown: {
class:

View File

@@ -1,64 +0,0 @@
<template>
<div class="relative w-full p-4">
<div class="h-12 flex items-center gap-4 justify-between">
<div class="flex-1 max-w-md">
<AutoComplete
v-model.lazy="searchQuery"
:placeholder="$t('templateWorkflows.searchPlaceholder')"
:complete-on-focus="false"
:delay="200"
class="w-full"
:pt="{
pcInputText: {
root: {
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
</div>
</div>
<div class="flex items-center gap-4 mt-2">
<small
v-if="searchQuery && filteredCount !== null"
class="text-color-secondary"
>
{{ $t('g.resultsCount', { count: filteredCount }) }}
</small>
<Button
v-if="searchQuery"
text
size="small"
icon="pi pi-times"
:label="$t('g.clearFilters')"
@click="clearFilters"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
const { filteredCount } = defineProps<{
filteredCount?: number | null
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
clearFilters: []
}>()
const clearFilters = () => {
searchQuery.value = ''
emit('clearFilters')
}
</script>

View File

@@ -1,273 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
default: {
name: 'AudioThumbnail',
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
props: ['src']
}
}))
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
default: {
name: 'CompareSliderThumbnail',
template:
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
}
}))
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
default: {
name: 'DefaultThumbnail',
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
}
}))
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
default: {
name: 'HoverDissolveThumbnail',
template:
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
}
}))
vi.mock('@vueuse/core', () => ({
useElementHover: () => ref(false)
}))
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
isLoaded: true,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: []
})
})
)
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback: string) => fallback || key
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
getTemplateThumbnailUrl: (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? `/fileURL/templates/${template.name}`
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
const indexSuffix =
sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
},
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
},
getTemplateDescription: (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
},
loadWorkflowTemplate: vi.fn()
})
})
)
describe('TemplateWorkflowCard', () => {
const createTemplate = (overrides = {}): TemplateInfo => ({
name: 'test-template',
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: 'Test description',
...overrides
})
const mountCard = (props = {}) => {
return mount(TemplateWorkflowCard, {
props: {
sourceModule: 'default',
categoryTitle: 'Test Category',
loading: false,
template: createTemplate(),
...props
},
global: {
stubs: {
Card: {
template:
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
props: ['dataTestid', 'pt']
},
ProgressSpinner: {
template: '<div class="progress-spinner"></div>'
}
}
}
})
}
it('emits loadWorkflow event when clicked', async () => {
const wrapper = mountCard({
template: createTemplate({ name: 'test-workflow' })
})
await wrapper.find('.card').trigger('click')
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
})
it('shows loading spinner when loading is true', () => {
const wrapper = mountCard({ loading: true })
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
})
it('renders audio thumbnail for audio media type', () => {
const wrapper = mountCard({
template: createTemplate({ mediaType: 'audio' })
})
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
})
it('renders compare slider thumbnail for compareSlider variant', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'compareSlider' })
})
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
})
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
})
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
})
it('renders default thumbnail by default', () => {
const wrapper = mountCard()
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
})
it('passes correct props to default thumbnail for video', () => {
const wrapper = mountCard({
template: createTemplate({ mediaType: 'video' })
})
const thumbnail = wrapper.find('.mock-default-thumbnail')
expect(thumbnail.exists()).toBe(true)
})
it('uses zoomHover scale when variant is zoomHover', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'zoomHover' })
})
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
})
it('displays localized title for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({ localizedTitle: 'My Localized Title' })
})
expect(wrapper.text()).toContain('My Localized Title')
})
it('displays template name as title for non-default source modules', () => {
const wrapper = mountCard({
sourceModule: 'custom',
template: createTemplate({ name: 'custom-template' })
})
expect(wrapper.text()).toContain('custom-template')
})
it('displays localized description for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({
localizedDescription: 'My Localized Description'
})
})
expect(wrapper.text()).toContain('My Localized Description')
})
it('processes description for non-default source modules', () => {
const wrapper = mountCard({
sourceModule: 'custom',
template: createTemplate({ description: 'custom_module-description' })
})
expect(wrapper.text()).toContain('custom module description')
})
it('generates correct thumbnail URLs for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({
name: 'my-template',
mediaSubtype: 'jpg'
})
})
const vm = wrapper.vm as any
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
})
it('generates correct thumbnail URLs for custom source module', () => {
const wrapper = mountCard({
sourceModule: 'custom-module',
template: createTemplate({
name: 'my-template',
mediaSubtype: 'png'
})
})
const vm = wrapper.vm as any
expect(vm.baseThumbnailSrc).toBe(
'/apiURL/workflow_templates/custom-module/my-template.png'
)
expect(vm.overlayThumbnailSrc).toBe(
'/apiURL/workflow_templates/custom-module/my-template.png'
)
})
})

View File

@@ -1,139 +0,0 @@
<template>
<Card
ref="cardRef"
:data-testid="`template-workflow-${template.name}`"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
@click="$emit('loadWorkflow', template.name)"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="baseThumbnailSrc" />
</template>
<template v-else-if="template.thumbnailVariant === 'compareSlider'">
<CompareSliderThumbnail
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
<HoverDissolveThumbnail
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
<DefaultThumbnail
:src="baseThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover'
? UPSCALE_ZOOM_SCALE
: DEFAULT_ZOOM_SCALE
"
/>
</template>
<ProgressSpinner
v-if="loading"
class="absolute inset-0 z-1 w-3/12 h-full"
/>
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
{{ title }}
</h3>
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
const DEFAULT_ZOOM_SCALE = 5
const { sourceModule, loading, template } = defineProps<{
sourceModule: string
categoryTitle: string
loading: boolean
template: TemplateInfo
}>()
const cardRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(cardRef)
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
const baseThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
)
const overlayThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
)
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
defineEmits<{
loadWorkflow: [name: string]
}>()
</script>

View File

@@ -1,30 +0,0 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,68 +0,0 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="enrichedTemplates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
</span>
</template>
</Column>
<Column field="actions" header="" class="w-12">
<template #body="slotProps">
<Button
icon="pi pi-arrow-right"
text
rounded
size="small"
:loading="loading === slotProps.data.name"
@click="emit('loadWorkflow', slotProps.data.name)"
/>
</template>
</Column>
</DataTable>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { computed, ref } from 'vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const { sourceModule, loading, templates } = defineProps<{
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
}>()
const selectedTemplate = ref(null)
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
const enrichedTemplates = computed(() => {
return templates.map((template) => {
const actualSourceModule = template.sourceModule || sourceModule
return {
...template,
title: getTemplateTitle(template, actualSourceModule),
description: getTemplateDescription(template, actualSourceModule)
}
})
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
</script>

View File

@@ -1,185 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
vi.mock('primevue/dataview', () => ({
default: {
name: 'DataView',
template: `
<div class="p-dataview">
<div class="dataview-header"><slot name="header"></slot></div>
<div class="dataview-content">
<slot name="grid" :items="value"></slot>
</div>
</div>
`,
props: ['value', 'layout', 'lazy', 'pt']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
template:
'<div class="p-selectbutton"><slot name="option" :option="modelValue"></slot></div>',
props: ['modelValue', 'options', 'allowEmpty']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
default: {
template: `
<div
class="mock-template-card"
:data-name="template.name"
:data-source-module="sourceModule"
:data-category-title="categoryTitle"
:data-loading="loading"
@click="$emit('loadWorkflow', template.name)"
></div>
`,
props: ['sourceModule', 'categoryTitle', 'loading', 'template'],
emits: ['loadWorkflow']
}
}))
vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
default: {
template: '<div class="mock-template-list"></div>',
props: ['sourceModule', 'categoryTitle', 'loading', 'templates'],
emits: ['loadWorkflow']
}
}))
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: ['searchQuery', 'filteredCount'],
emits: ['update:searchQuery', 'clearFilters']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
vi.mock('@/composables/useIntersectionObserver', () => ({
useIntersectionObserver: vi.fn()
}))
vi.mock('@/composables/useLazyPagination', () => ({
useLazyPagination: (items: any) => ({
paginatedItems: items,
isLoading: { value: false },
hasMoreItems: { value: false },
loadNextPage: vi.fn(),
reset: vi.fn()
})
}))
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 }
})
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: `Description for ${name}`
})
const mountView = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWorkflows: {
loadingMore: 'Loading more...'
}
}
}
})
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
sourceModule: 'default',
categoryTitle: 'Test Category',
templates: [
createTemplate('template-1'),
createTemplate('template-2'),
createTemplate('template-3')
],
loading: null,
...props
},
global: {
plugins: [i18n]
}
})
}
it('renders template cards for each template', () => {
const wrapper = mountView()
const cards = wrapper.findAll('.mock-template-card')
expect(cards.length).toBe(3)
expect(cards[0].attributes('data-name')).toBe('template-1')
expect(cards[1].attributes('data-name')).toBe('template-2')
expect(cards[2].attributes('data-name')).toBe('template-3')
})
it('emits loadWorkflow event when clicked', async () => {
const wrapper = mountView()
const card = wrapper.find('.mock-template-card')
await card.trigger('click')
expect(wrapper.emitted()).toHaveProperty('loadWorkflow')
// Check that the emitted event contains the template name
const emitted = wrapper.emitted('loadWorkflow')
expect(emitted).toBeTruthy()
expect(emitted?.[0][0]).toBe('template-1')
})
it('passes correct props to template cards', () => {
const wrapper = mountView({
sourceModule: 'custom',
categoryTitle: 'Custom Category'
})
const card = wrapper.find('.mock-template-card')
expect(card.exists()).toBe(true)
expect(card.attributes('data-source-module')).toBe('custom')
expect(card.attributes('data-category-title')).toBe('Custom Category')
})
it('applies loading state correctly to cards', () => {
const wrapper = mountView({
loading: 'template-2'
})
const cards = wrapper.findAll('.mock-template-card')
// Only the second card should have loading=true since loading="template-2"
expect(cards[0].attributes('data-loading')).toBe('false')
expect(cards[1].attributes('data-loading')).toBe('true')
expect(cards[2].attributes('data-loading')).toBe('false')
})
})

View File

@@ -1,168 +0,0 @@
<template>
<DataView
:value="displayTemplates"
:layout="layout"
data-key="name"
:lazy="true"
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
pt:content="p-2 overflow-auto"
>
<template #header>
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
<TemplateSearchBar
v-model:search-query="searchQuery"
:filtered-count="filteredCount"
@clear-filters="() => reset()"
/>
</div>
</template>
<template #list="{ items }">
<TemplateWorkflowList
:source-module="sourceModule"
:templates="items"
:loading="loading"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
</template>
<template #grid="{ items }">
<div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<TemplateWorkflowCardSkeleton
v-for="n in shouldUsePagination && isLoadingMore
? skeletonCount
: 0"
:key="`skeleton-${n}`"
/>
</div>
<div
v-if="shouldUsePagination && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ t('templateWorkflows.loadingMore') }}
</div>
</div>
</div>
</template>
</DataView>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const { t } = useI18n()
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
title: string
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
}>()
const layout = useLocalStorage<'grid' | 'list'>(
'Comfy.TemplateWorkflow.Layout',
'grid'
)
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
const templatesRef = computed(() => templates || [])
const { searchQuery, filteredTemplates, filteredCount } =
useTemplateFiltering(templatesRef)
// When searching, show all results immediately without pagination
// When not searching, use lazy pagination
const shouldUsePagination = computed(() => !searchQuery.value.trim())
// Lazy pagination setup using filtered templates
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset
} = useLazyPagination(filteredTemplates, {
itemsPerPage: 12
})
// Final templates to display
const displayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
loadTrigger,
(entries) => {
const entry = entries[0]
if (
entry?.isIntersecting &&
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
},
{
rootMargin: '200px',
threshold: 0.1
}
)
watch([() => templates, searchQuery], () => {
reset()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
const onLoadWorkflow = (name: string) => {
emit('loadWorkflow', name)
}
</script>

View File

@@ -1,112 +0,0 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
data-testid="template-workflows-content"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<Divider
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
/>
<div class="flex flex-1 relative overflow-hidden">
<aside
v-if="isSideNavOpen"
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
/>
</aside>
<div
class="flex-1 transition-all duration-300"
:class="{
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
}
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
}
</script>

View File

@@ -1,7 +0,0 @@
<template>
<div>
<h3 class="px-4">
<span>{{ $t('templateWorkflows.title') }}</span>
</h3>
</div>
</template>

View File

@@ -1,50 +0,0 @@
<template>
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
<Listbox
:model-value="selectedTab"
:options="tabs"
option-group-label="label"
option-label="localizedTitle"
option-group-children="modules"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-12 py-3 text-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
list-style="max-height:unset"
@update:model-value="handleTabSelection"
>
<template #optiongroup="slotProps">
<div class="text-left py-3 px-12">
<h2 class="text-lg">
{{ slotProps.option.label }}
</h2>
</div>
</template>
</Listbox>
</ScrollPanel>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import type {
TemplateGroup,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
defineProps<{
tabs: TemplateGroup[]
selectedTab: WorkflowTemplates | null
}>()
const emit = defineEmits<{
(e: 'update:selectedTab', tab: WorkflowTemplates): void
}>()
const handleTabSelection = (tab: WorkflowTemplates) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -1,6 +1,12 @@
<template>
<BaseThumbnail>
<div class="w-full h-full flex items-center justify-center p-4">
<div
class="w-full h-full flex items-center justify-center p-4"
:style="{
backgroundImage: 'url(/assets/images/default-template.png)',
backgroundRepeat: 'round'
}"
>
<audio controls class="w-full relative" :src="src" @click.stop />
</div>
</BaseThumbnail>

View File

@@ -51,8 +51,9 @@ describe('BaseThumbnail', () => {
vm.error = true
await nextTick()
expect(wrapper.find('.pi-file').exists()).toBe(true)
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
expect(
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
).toBe(true)
})
it('applies transition classes to content', () => {

View File

@@ -1,5 +1,7 @@
<template>
<div class="relative w-64 h-64 rounded-t-lg overflow-hidden select-none">
<div
class="relative w-full aspect-square rounded-t-lg overflow-hidden select-none"
>
<div
v-if="!error"
ref="contentRef"
@@ -11,7 +13,11 @@
<slot />
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<i class="pi pi-file text-4xl" />
<img
src="/assets/images/default-template.png"
draggable="false"
class="transform-gpu transition-transform duration-300 ease-out w-full h-full object-cover"
/>
</div>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<i :class="icon" class="text-xs text-neutral" />
<i :class="icon" class="text-sm text-neutral" />
</template>
<script setup lang="ts">

View File

@@ -1,13 +1,55 @@
<template>
<h3
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
<div
:class="
cn(
'flex items-center justify-between m-0 px-3 py-0 pt-5',
collapsible && 'cursor-pointer select-none'
)
"
@click="collapsible && toggleCollapse()"
>
{{ title }}
</h3>
<h3
class="text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
>
{{ title }}
</h3>
<i
v-if="collapsible"
:class="
cn(
'pi transition-transform duration-200 text-xs text-neutral-400 dark-theme:text-neutral-400',
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
)
"
/>
</div>
</template>
<script setup lang="ts">
const { title } = defineProps<{
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
title,
modelValue = false,
collapsible = false
} = defineProps<{
title: string
modelValue?: boolean
collapsible?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isCollapsed = computed({
get: () => modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
</script>

View File

@@ -7,19 +7,27 @@
<slot name="header-title"></slot>
</PanelHeader>
<nav class="flex-1 px-3 py-4 flex flex-col gap-1">
<nav
class="flex-1 px-3 py-4 flex flex-col gap-1 overflow-y-auto scrollbar-hide"
>
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle :title="item.title" />
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
@@ -36,7 +44,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
@@ -53,6 +61,9 @@ const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
// Track collapsed state for each group
const collapsedGroups = ref<Record<string, boolean>>({})
const getFirstItemId = () => {
if (!navItems || navItems.length === 0) {
return null

View File

@@ -51,6 +51,8 @@ import {
} from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
@@ -264,7 +266,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-folder-open',
label: 'Browse Templates',
function: () => {
dialogService.showTemplateWorkflowsDialog()
useWorkflowTemplateSelectorDialog().show()
}
},
{

View File

@@ -1,57 +1,205 @@
import { refDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
// @ts-expect-error unused (To be used later?)
interface TemplateFilterOptions {
searchQuery?: string
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const searchQuery = ref('')
const selectedModels = ref<string[]>([])
const selectedUseCases = ref<string[]>([])
const selectedLicenses = ref<string[]>([])
const sortBy = ref<
'default' | 'alphabetical' | 'newest' | 'model-size-low-to-high'
>('newest')
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
const filteredTemplates = computed(() => {
const templateData = templatesArray.value
if (templateData.length === 0) {
return []
// Fuse.js configuration for fuzzy search
const fuseOptions = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.2 },
{ name: 'tags', weight: 0.1 },
{ name: 'models', weight: 0.1 }
],
threshold: 0.4,
includeScore: true,
includeMatches: true
}
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
const availableModels = computed(() => {
const modelSet = new Set<string>()
templatesArray.value.forEach((template) => {
if (Array.isArray(template.models)) {
template.models.forEach((model) => modelSet.add(model))
}
})
return Array.from(modelSet).sort()
})
const availableUseCases = computed(() => {
const tagSet = new Set<string>()
templatesArray.value.forEach((template) => {
if (template.tags && Array.isArray(template.tags)) {
template.tags.forEach((tag) => tagSet.add(tag))
}
})
return Array.from(tagSet).sort()
})
const availableLicenses = computed(() => {
return ['Open Source', 'Closed Source (API Nodes)']
})
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return templatesArray.value
}
if (!searchQuery.value.trim()) {
return templateData
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
})
const filteredByModels = computed(() => {
if (selectedModels.value.length === 0) {
return filteredBySearch.value
}
const query = searchQuery.value.toLowerCase().trim()
return templateData.filter((template) => {
const searchableText = [
template.name,
template.description,
template.sourceModule
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
return filteredBySearch.value.filter((template) => {
if (!template.models || !Array.isArray(template.models)) {
return false
}
return selectedModels.value.some((selectedModel) =>
template.models?.includes(selectedModel)
)
})
})
const filteredByUseCases = computed(() => {
if (selectedUseCases.value.length === 0) {
return filteredByModels.value
}
return filteredByModels.value.filter((template) => {
if (!template.tags || !Array.isArray(template.tags)) {
return false
}
return selectedUseCases.value.some((selectedTag) =>
template.tags?.includes(selectedTag)
)
})
})
const filteredByLicenses = computed(() => {
if (selectedLicenses.value.length === 0) {
return filteredByUseCases.value
}
return filteredByUseCases.value.filter((template) => {
// Check if template has API in its tags or name (indicating it's a closed source API node)
const isApiTemplate =
template.tags?.includes('API') ||
template.name?.toLowerCase().includes('api_')
return selectedLicenses.value.some((selectedLicense) => {
if (selectedLicense === 'Closed Source (API Nodes)') {
return isApiTemplate
} else if (selectedLicense === 'Open Source') {
return !isApiTemplate
}
return false
})
})
})
const sortedTemplates = computed(() => {
const templates = [...filteredByLicenses.value]
switch (sortBy.value) {
case 'alphabetical':
return templates.sort((a, b) => {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
})
case 'newest':
return templates.sort((a, b) => {
const dateA = new Date(a.date || '1970-01-01')
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
case 'model-size-low-to-high':
return templates.sort((a: any, b: any) => {
const sizeA =
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
if (sizeA === sizeB) return 0
return sizeA - sizeB
})
case 'default':
default:
// Keep original order (default order)
return templates
}
})
const filteredTemplates = computed(() => sortedTemplates.value)
const resetFilters = () => {
searchQuery.value = ''
selectedModels.value = []
selectedUseCases.value = []
selectedLicenses.value = []
sortBy.value = 'default'
}
const removeModelFilter = (model: string) => {
selectedModels.value = selectedModels.value.filter((m) => m !== model)
}
const removeUseCaseFilter = (tag: string) => {
selectedUseCases.value = selectedUseCases.value.filter((t) => t !== tag)
}
const removeLicenseFilter = (license: string) => {
selectedLicenses.value = selectedLicenses.value.filter((l) => l !== license)
}
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => templatesArray.value.length)
return {
// State
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
sortBy,
// Computed
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
resetFilters
totalCount,
// Methods
resetFilters,
removeModelFilter,
removeUseCaseFilter,
removeLicenseFilter
}
}

View File

@@ -0,0 +1,38 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-workflow-template-selector'
export const useWorkflowTemplateSelectorDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,
props: {
onClose: hide
},
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-hidden h-full !py-0' },
root: {
style:
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
}
}
}
})
}
return {
show,
hide
}
}

View File

@@ -169,6 +169,7 @@
"nodesRunning": "nodes running",
"duplicate": "Duplicate",
"moreWorkflows": "More workflows",
"seeTutorial": "See a tutorial",
"nodeRenderError": "Node Render Error",
"nodeContentError": "Node Content Error",
"nodeHeaderError": "Node Header Error",
@@ -685,6 +686,7 @@
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
"Basics": "Basics",
"GettingStarted": "Getting Started",
"Flux": "Flux",
"ControlNet": "ControlNet",
"Upscaling": "Upscaling",
@@ -693,6 +695,7 @@
"Area Composition": "Area Composition",
"3D": "3D",
"Audio": "Audio",
"LLMs": "LLMs",
"Image API": "Image API",
"Video API": "Video API",
"LLM API": "LLM API",
@@ -1001,6 +1004,24 @@
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
}
},
"categories": "Categories",
"resetFilters": "Clear Filters",
"sorting": "Sort by",
"activeFilters": "Filters:",
"loading": "Loading templates...",
"noResults": "No templates found",
"noResultsHint": "Try adjusting your search or filters",
"modelFilter": "Model Filter",
"modelsSelected": "{count} Models",
"useCasesSelected": "{count} Use Cases",
"licensesSelected": "{count} Licenses",
"resultsCount": "Showing {count} of {total} templates",
"sort": {
"recommended": "Recommended",
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search..."
}
},
"graphCanvasMenu": {

View File

@@ -60,7 +60,7 @@ export function useTemplateWorkflows() {
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = ''
index = '1'
) => {
const basePath =
sourceModule === 'default'
@@ -85,13 +85,12 @@ export function useTemplateWorkflows() {
/**
* Gets formatted template description
*/
const getTemplateDescription = (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
const getTemplateDescription = (template: TemplateInfo) => {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}
/**

View File

@@ -1,23 +1,28 @@
import { groupBy } from 'es-toolkit/compat'
import Fuse from 'fuse.js'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { st } from '@/i18n'
import { i18n, st } from '@/i18n'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { getCategoryIcon } from '@/utils/categoryIcons'
import { normalizeI18nKey } from '@/utils/formatUtil'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { normalizeI18nKey } from '@/utils/formatUtil'
} from '../types/template'
const SHOULD_SORT_CATEGORIES = new Set([
// API Node templates should be strictly sorted by name to avoid any
// favoritism or bias towards a particular API. Other categories can
// have their ordering specified in index.json freely.
'Image API',
'Video API'
])
// Enhanced template interface for easier filtering
interface EnhancedTemplate extends TemplateInfo {
sourceModule: string
category?: string
categoryType?: string
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
isEssential?: boolean
searchableText?: string
}
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
@@ -26,36 +31,13 @@ export const useWorkflowTemplatesStore = defineStore(
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
/**
* Sort a list of templates in alphabetical order by localized display name.
*/
const sortTemplateList = (templates: TemplateInfo[]) =>
templates.sort((a, b) => {
const aName = st(
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
a.title ?? a.name
)
const bName = st(
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
b.name
)
return aName.localeCompare(bName)
})
// Store filter mappings for dynamic categories
type FilterData = {
category?: string
categoryGroup?: string
}
/**
* Sort any template categories (grouped templates) that should be sorted.
* Leave other categories' templates in their original order specified in index.json
*/
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
categories.map((category) => {
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
return {
...category,
templates: sortTemplateList(category.templates)
}
}
return category
})
const categoryFilters = ref(new Map<string, FilterData>())
/**
* Add localization fields to a template.
@@ -144,12 +126,13 @@ export const useWorkflowTemplatesStore = defineStore(
}
}
/**
* Original grouped templates for backward compatibility
*/
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
...sortCategoryTemplates(coreTemplates.value).map(
localizeTemplateCategory
),
...coreTemplates.value.map(localizeTemplateCategory),
...Object.entries(customTemplates.value).map(
([moduleName, templates]) => ({
moduleName,
@@ -169,38 +152,286 @@ export const useWorkflowTemplatesStore = defineStore(
]
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
const groupedByCategory = [
{
label: st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
),
modules: [
createAllCategory(),
...allTemplates.filter((t) => t.moduleName === 'default')
]
},
...(Object.keys(customTemplates.value).length > 0
? [
{
label: st(
'templateWorkflows.category.Custom Nodes',
'Custom Nodes'
),
modules: allTemplates.filter((t) => t.moduleName !== 'default')
}
]
: [])
]
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
return groupedByCategory
})
/**
* Enhanced templates with proper categorization for filtering
*/
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
const allTemplates: EnhancedTemplate[] = []
// Process core templates
coreTemplates.value.forEach((category) => {
category.templates.forEach((template) => {
const enhancedTemplate: EnhancedTemplate = {
...template,
sourceModule: category.moduleName,
category: category.title,
categoryType: category.type,
categoryGroup: category.category,
isEssential: category.isEssential,
searchableText: [
template.title || template.name,
template.description || '',
category.title,
...(template.tags || []),
...(template.models || [])
].join(' ')
}
allTemplates.push(enhancedTemplate)
})
})
// Process custom templates
Object.entries(customTemplates.value).forEach(
([moduleName, templates]) => {
templates.forEach((name) => {
const enhancedTemplate: EnhancedTemplate = {
name,
title: name,
description: name,
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: moduleName,
category: 'Extensions',
categoryType: 'extension',
searchableText: `${name} ${moduleName} extension`
}
allTemplates.push(enhancedTemplate)
})
}
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
return allTemplates
})
/**
* Fuse.js instance for advanced template searching and filtering
*/
const templateFuse = computed(() => {
const fuseOptions = {
keys: [
{ name: 'searchableText', weight: 0.4 },
{ name: 'title', weight: 0.3 },
{ name: 'name', weight: 0.2 },
{ name: 'tags', weight: 0.1 }
],
threshold: 0.3,
includeScore: true
}
return new Fuse(enhancedTemplates.value, fuseOptions)
})
/**
* Filter templates by category ID using stored filter mappings
*/
const filterTemplatesByCategory = (categoryId: string) => {
if (categoryId === 'all') {
return enhancedTemplates.value
}
if (categoryId === 'basics') {
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter((t) => t.isEssential)
}
// Handle extension-specific filters
if (categoryId.startsWith('extension-')) {
const moduleName = categoryId.replace('extension-', '')
return enhancedTemplates.value.filter(
(t) => t.sourceModule === moduleName
)
}
return groupedByCategory
// Look up the filter from our stored mappings
const filter = categoryFilters.value.get(categoryId)
if (!filter) {
return enhancedTemplates.value
}
// Apply the filter
return enhancedTemplates.value.filter((template) => {
if (filter.category && template.category !== filter.category) {
return false
}
if (
filter.categoryGroup &&
template.categoryGroup !== filter.categoryGroup
) {
return false
}
return true
})
}
/**
* New navigation structure dynamically built from JSON categories
*/
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
if (!isLoaded.value) return []
const items: (NavItemData | NavGroupData)[] = []
// Clear and rebuild filter mappings
categoryFilters.value.clear()
// 1. All Templates - always first
items.push({
id: 'all',
label: st('templateWorkflows.category.All', 'All Templates'),
icon: getCategoryIcon('all')
})
// 2. Basics (isEssential categories) - always second if it exists
let gettingStartedText = 'Getting Started'
const essentialCat = coreTemplates.value.find(
(cat) => cat.isEssential && cat.templates.length > 0
)
const hasEssentialCategories = Boolean(essentialCat)
if (essentialCat) {
gettingStartedText = essentialCat.title
}
if (hasEssentialCategories) {
items.push({
id: 'basics',
label: gettingStartedText,
icon: 'icon-[lucide--graduation-cap]'
})
}
// 3. Group categories from JSON dynamically
const categoryGroups = new Map<
string,
{ title: string; items: NavItemData[] }
>()
// Process all categories from JSON
coreTemplates.value.forEach((category) => {
// Skip essential categories as they're handled as Basics
if (category.isEssential) return
const categoryGroup = category.category
const categoryIcon = category.icon
if (categoryGroup) {
if (!categoryGroups.has(categoryGroup)) {
categoryGroups.set(categoryGroup, {
title: categoryGroup,
items: []
})
}
const group = categoryGroups.get(categoryGroup)!
// Generate unique ID for this category
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
// Store the filter mapping
categoryFilters.value.set(categoryId, {
category: category.title,
categoryGroup: categoryGroup
})
group.items.push({
id: categoryId,
label: st(
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
category.title
),
icon: categoryIcon || getCategoryIcon(category.type || 'default')
})
}
})
// Add grouped categories
categoryGroups.forEach((group, groupName) => {
if (group.items.length > 0) {
items.push({
title: st(
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
groupName
.split(' ')
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ')
),
items: group.items
})
}
})
// 4. Extensions - always last
const extensionCounts = enhancedTemplates.value.filter(
(t) => t.sourceModule !== 'default'
).length
if (extensionCounts > 0) {
// Get unique extension modules
const extensionModules = Array.from(
new Set(
enhancedTemplates.value
.filter((t) => t.sourceModule !== 'default')
.map((t) => t.sourceModule)
)
).sort()
const extensionItems: NavItemData[] = extensionModules.map(
(moduleName) => ({
id: `extension-${moduleName}`,
label: st(
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
moduleName
),
icon: getCategoryIcon('extensions')
})
)
items.push({
title: st('templateWorkflows.category.Extensions', 'Extensions'),
items: extensionItems,
collapsible: true
})
}
return items
})
async function loadWorkflowTemplates() {
try {
if (!isLoaded.value) {
customTemplates.value = await api.getWorkflowTemplates()
coreTemplates.value = await api.getCoreWorkflowTemplates()
const locale = i18n.global.locale.value
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
isLoaded.value = true
}
} catch (error) {
@@ -210,6 +441,10 @@ export const useWorkflowTemplatesStore = defineStore(
return {
groupedTemplates,
navGroupedTemplates,
enhancedTemplates,
templateFuse,
filterTemplatesByCategory,
isLoaded,
loadWorkflowTemplates
}

View File

@@ -11,13 +11,25 @@ export interface TemplateInfo {
description: string
localizedTitle?: string
localizedDescription?: string
isEssential?: boolean
sourceModule?: string
tags?: string[]
models?: string[]
date?: string
useCase?: string
license?: string
size?: number
}
export interface WorkflowTemplates {
moduleName: string
templates: TemplateInfo[]
title: string
localizedTitle?: string
category?: string
type?: string
icon?: string
isEssential?: boolean
}
export interface TemplateGroup {

View File

@@ -603,11 +603,28 @@ export class ComfyApi extends EventTarget {
/**
* Gets the index of core workflow templates.
* @param locale Optional locale code (e.g., 'en', 'fr', 'zh') to load localized templates
*/
async getCoreWorkflowTemplates(): Promise<WorkflowTemplates[]> {
const res = await axios.get(this.fileURL('/templates/index.json'))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
async getCoreWorkflowTemplates(
locale?: string
): Promise<WorkflowTemplates[]> {
const fileName =
locale && locale !== 'en' ? `index.${locale}.json` : 'index.json'
try {
const res = await axios.get(this.fileURL(`/templates/${fileName}`))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
} catch (error) {
// Fallback to default English version if localized version doesn't exist
if (locale && locale !== 'en') {
console.warn(
`Localized templates for '${locale}' not found, falling back to English`
)
return this.getCoreWorkflowTemplates()
}
console.error('Error loading core workflow templates:', error)
return []
}
}
/**

View File

@@ -12,8 +12,6 @@ import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsD
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
import { t } from '@/i18n'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
@@ -111,23 +109,6 @@ export const useDialogService = () => {
})
}
function showTemplateWorkflowsDialog(
props: InstanceType<typeof TemplateWorkflowsContent>['$props'] = {}
) {
dialogStore.showDialog({
key: 'global-template-workflows',
title: t('templateWorkflows.title'),
component: TemplateWorkflowsContent,
headerComponent: TemplateWorkflowsDialogHeader,
dialogComponentProps: {
pt: {
content: { class: 'px-0! overflow-y-hidden' }
}
},
props
})
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
) {
@@ -155,30 +136,6 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: 'p-0!' },
header: { class: 'p-0! border-none' },
footer: { class: 'p-0! border-none' }
}
}
})
}
function parseError(error: Error) {
const filename =
'fileName' in error
@@ -235,6 +192,30 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: 'p-0!' },
header: { class: 'p-0! border-none' },
footer: { class: 'p-0! border-none' }
}
}
})
}
/**
* Shows a dialog requiring sign in for API nodes
* @returns Promise that resolves to true if user clicks login, false if cancelled
@@ -511,16 +492,15 @@ export const useDialogService = () => {
showSettingsDialog,
showAboutDialog,
showExecutionErrorDialog,
showTemplateWorkflowsDialog,
showManagerDialog,
showManagerProgressDialog,
showErrorDialog,
showApiNodesSignInDialog,
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
showErrorDialog,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog,

View File

@@ -7,4 +7,6 @@ export interface NavItemData {
export interface NavGroupData {
title: string
items: NavItemData[]
icon?: string
collapsible?: boolean
}

View File

@@ -0,0 +1,52 @@
/**
* Maps category IDs to their corresponding Lucide icon classes
*/
export const getCategoryIcon = (categoryId: string): string => {
const iconMap: Record<string, string> = {
// Main categories
all: 'icon-[lucide--list]',
'getting-started': 'icon-[lucide--graduation-cap]',
// Generation types
'generation-image': 'icon-[lucide--image]',
image: 'icon-[lucide--image]',
'generation-video': 'icon-[lucide--film]',
video: 'icon-[lucide--film]',
'generation-3d': 'icon-[lucide--box]',
'3d': 'icon-[lucide--box]',
'generation-audio': 'icon-[lucide--volume-2]',
audio: 'icon-[lucide--volume-2]',
'generation-llm': 'icon-[lucide--message-square-text]',
// API and models
'api-nodes': 'icon-[lucide--hand-coins]',
'closed-models': 'icon-[lucide--hand-coins]',
// LLMs and AI
llm: 'icon-[lucide--message-square-text]',
llms: 'icon-[lucide--message-square-text]',
'llm-api': 'icon-[lucide--message-square-text]',
// Performance and hardware
'small-models': 'icon-[lucide--zap]',
performance: 'icon-[lucide--zap]',
'mac-compatible': 'icon-[lucide--command]',
'runs-on-mac': 'icon-[lucide--command]',
// Training
'lora-training': 'icon-[lucide--dumbbell]',
training: 'icon-[lucide--dumbbell]',
// Extensions and tools
extensions: 'icon-[lucide--puzzle]',
tools: 'icon-[lucide--wrench]',
// Fallbacks for common patterns
upscaling: 'icon-[lucide--maximize-2]',
controlnet: 'icon-[lucide--sliders-horizontal]',
'area-composition': 'icon-[lucide--layout-grid]'
}
// Return mapped icon or fallback to folder
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
}

View File

@@ -225,28 +225,22 @@ describe('useTemplateWorkflows', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription(
{
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
const descWithLocalized = getTemplateDescription({
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
})
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription(
{
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
},
'custom-module'
)
const customDesc = getTemplateDescription({
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
})
expect(customDesc).toBe('custom template description')
})