mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-16 10:27:32 +00:00
Compare commits
36 Commits
fetch-node
...
core/1.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
429b8799c0 | ||
|
|
edff418741 | ||
|
|
ead7e190f8 | ||
|
|
df2feea227 | ||
|
|
5d87cf4f8a | ||
|
|
ec26daa020 | ||
|
|
ca72839af9 | ||
|
|
bb400f181a | ||
|
|
27dcc19a4b | ||
|
|
6289ac9182 | ||
|
|
86a7dd05a3 | ||
|
|
dee00edc5f | ||
|
|
afac449f41 | ||
|
|
aca1a2a194 | ||
|
|
4dfe75d68b | ||
|
|
2c37dba143 | ||
|
|
3936454ffd | ||
|
|
30ee669f5c | ||
|
|
811ddd6165 | ||
|
|
0cdaa512c8 | ||
|
|
3a514ca63b | ||
|
|
405b5fc5b7 | ||
|
|
0eaf7d11b6 | ||
|
|
fa58c04b3a | ||
|
|
9c84c9e250 | ||
|
|
6f9f048b4a | ||
|
|
768faeee7e | ||
|
|
eba81efb4b | ||
|
|
f9d92b8198 | ||
|
|
c4bbe7fee1 | ||
|
|
8f4f5f8e5f | ||
|
|
9e137d9924 | ||
|
|
a084b55db7 | ||
|
|
835f318999 | ||
|
|
c35d44c491 | ||
|
|
38d3e15103 |
43
.claude/commands/add-missing-i18n.md
Normal file
43
.claude/commands/add-missing-i18n.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Add Missing i18n Translations
|
||||
|
||||
## Task: Add English translations for all new localized strings
|
||||
|
||||
### Step 1: Identify new translation keys
|
||||
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
|
||||
|
||||
### Step 2: Add translations to English locale file
|
||||
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
|
||||
|
||||
### Key-to-JSON mapping rules:
|
||||
- Translation keys use dot notation to represent nested JSON structure
|
||||
- Convert dot notation to nested JSON objects when adding to the locale file
|
||||
- Example: The key `g.user.name` maps to:
|
||||
```json
|
||||
{
|
||||
"g": {
|
||||
"user": {
|
||||
"name": "User Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important notes:
|
||||
1. **Only modify the English locale file** (`src/locales/en/main.json`)
|
||||
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
|
||||
3. **Exception for manual translations**: Only add translations to non-English locale files if:
|
||||
- You have specific domain knowledge that would produce a more accurate translation than the automated system
|
||||
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
|
||||
|
||||
### Example workflow:
|
||||
1. If you added `t('settings.advanced.enable')` in a Vue component
|
||||
2. Add to `src/locales/en/main.json`:
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"advanced": {
|
||||
"enable": "Enable advanced settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
6
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
6
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,7 +1,9 @@
|
||||
name: Bug Report
|
||||
description: "Something is not behaving as expected."
|
||||
title: "[Bug]: "
|
||||
description: 'Something is not behaving as expected.'
|
||||
title: '[Bug]: '
|
||||
labels: ['Potential Bug']
|
||||
type: Bug
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
5
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature Request]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Feature Request]: '
|
||||
labels: ['enhancement']
|
||||
type: Feature
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
- use npm run to see what commands are available
|
||||
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
|
||||
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
|
||||
@@ -36,3 +35,4 @@
|
||||
- Follow Vue 3 style guide and naming conventions
|
||||
- Use Vite for fast development and building
|
||||
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
|
||||
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 57 KiB |
@@ -32,7 +32,9 @@ test.describe('Templates', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('should have all required thumbnail media for each template', async ({
|
||||
// TODO: Re-enable this test once issue resolved
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
|
||||
test.skip('should have all required thumbnail media for each template', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
59
build/plugins/addElementVnodeExportPlugin.ts
Normal file
59
build/plugins/addElementVnodeExportPlugin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Plugin } from 'vite'
|
||||
|
||||
/**
|
||||
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
|
||||
*
|
||||
* This plugin addresses compatibility issues where some components or libraries
|
||||
* might be using the older createElementVNode function name instead of createBaseVNode.
|
||||
* It modifies the Vue vendor chunk during build to add the alias export.
|
||||
*
|
||||
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
|
||||
*/
|
||||
export function addElementVnodeExportPlugin(): Plugin {
|
||||
return {
|
||||
name: 'add-element-vnode-export-plugin',
|
||||
|
||||
renderChunk(code, chunk, _options) {
|
||||
if (chunk.name.startsWith('vendor-vue')) {
|
||||
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
|
||||
const match = code.match(exportRegex)
|
||||
|
||||
if (match) {
|
||||
const existingExports = match[2].trim()
|
||||
const exportsArray = existingExports
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const hasCreateBaseVNode = exportsArray.some((e) =>
|
||||
e.startsWith('createBaseVNode')
|
||||
)
|
||||
const hasCreateElementVNode = exportsArray.some((e) =>
|
||||
e.includes('createElementVNode')
|
||||
)
|
||||
|
||||
if (hasCreateBaseVNode && !hasCreateElementVNode) {
|
||||
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
|
||||
const newCode = code.replace(exportRegex, newExportStatement)
|
||||
|
||||
console.log(
|
||||
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
|
||||
)
|
||||
|
||||
return { code: newCode, map: null }
|
||||
} else if (!hasCreateBaseVNode) {
|
||||
console.warn(
|
||||
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,9 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
|
||||
import type { OutputOptions } from 'rollup'
|
||||
import { HtmlTagDescriptor, Plugin } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
interface VendorLibrary {
|
||||
name: string
|
||||
pattern: string | RegExp
|
||||
entry: string
|
||||
recursiveDependence?: boolean
|
||||
override?: Record<string, Partial<ImportMapSource>>
|
||||
}
|
||||
|
||||
const parseDeps = (root: string, pkg: string) => {
|
||||
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const content = fs.readFileSync(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(content)
|
||||
return Object.keys(pkg.dependencies || {})
|
||||
}
|
||||
return []
|
||||
pattern: RegExp
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,89 +23,53 @@ const parseDeps = (root: string, pkg: string) => {
|
||||
* @returns {Plugin} A Vite plugin that generates and injects an import map
|
||||
*/
|
||||
export function generateImportMapPlugin(
|
||||
importMapSources: ImportMapSource[]
|
||||
vendorLibraries: VendorLibrary[]
|
||||
): Plugin {
|
||||
const importMapEntries: Record<string, string> = {}
|
||||
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
|
||||
const assetDir = 'assets/lib'
|
||||
let root: string
|
||||
|
||||
return {
|
||||
name: 'generate-import-map-plugin',
|
||||
|
||||
// Configure manual chunks during the build process
|
||||
configResolved(config) {
|
||||
root = config.root
|
||||
|
||||
if (config.build) {
|
||||
// Ensure rollupOptions exists
|
||||
if (!config.build.rollupOptions) {
|
||||
config.build.rollupOptions = {}
|
||||
}
|
||||
|
||||
for (const source of importMapSources) {
|
||||
resolvedImportMapSources.set(source.name, source)
|
||||
if (source.recursiveDependence) {
|
||||
const deps = parseDeps(root, source.name)
|
||||
|
||||
while (deps.length) {
|
||||
const dep = deps.shift()!
|
||||
const depSource = Object.assign({}, source, {
|
||||
name: dep,
|
||||
pattern: dep,
|
||||
...source.override?.[dep]
|
||||
})
|
||||
resolvedImportMapSources.set(depSource.name, depSource)
|
||||
|
||||
const _deps = parseDeps(root, depSource.name)
|
||||
deps.unshift(..._deps)
|
||||
const outputOptions: OutputOptions = {
|
||||
manualChunks: (id: string) => {
|
||||
for (const lib of vendorLibraries) {
|
||||
if (lib.pattern.test(id)) {
|
||||
return `vendor-${lib.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
// Disable minification of internal exports to preserve function names
|
||||
minifyInternalExports: false
|
||||
}
|
||||
|
||||
const external: (string | RegExp)[] = []
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
external.push(source.pattern)
|
||||
}
|
||||
config.build.rollupOptions.external = external
|
||||
config.build.rollupOptions.output = outputOptions
|
||||
}
|
||||
},
|
||||
|
||||
generateBundle(_options) {
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
if (source.entry) {
|
||||
const moduleFile = join(source.name, source.entry)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
generateBundle(_options, bundle) {
|
||||
for (const fileName in bundle) {
|
||||
const chunk = bundle[fileName]
|
||||
if (chunk.type === 'chunk' && !chunk.isEntry) {
|
||||
// Find matching vendor library by chunk name
|
||||
const vendorLib = vendorLibraries.find(
|
||||
(lib) => chunk.name === `vendor-${lib.name}`
|
||||
)
|
||||
|
||||
importMapEntries[source.name] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
if (vendorLib) {
|
||||
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
|
||||
importMapEntries[vendorLib.name] = relativePath
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
}
|
||||
|
||||
if (source.recursiveDependence) {
|
||||
const files = glob.sync(['**/*.{js,mjs}'], {
|
||||
cwd: join(root, 'node_modules', source.name)
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
const moduleFile = join(source.name, file)
|
||||
const sourceFile = join(root, 'node_modules', moduleFile)
|
||||
const targetFile = join(root, 'dist', assetDir, moduleFile)
|
||||
|
||||
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
|
||||
const targetDir = dirname(targetFile)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
fs.copyFileSync(sourceFile, targetFile)
|
||||
console.log(
|
||||
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
@@ -24,7 +24,7 @@ export default [
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
project: ['./tsconfig.json', './tsconfig.eslint.json'],
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions: ['.vue']
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.21.0",
|
||||
"version": "1.21.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.21.0",
|
||||
"version": "1.21.7",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.15.11",
|
||||
"@comfyorg/litegraph": "^0.15.15",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -788,9 +788,9 @@
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.15.11",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.11.tgz",
|
||||
"integrity": "sha512-gU8KK9cid7dXSK1yh3ReUolG0HGT3piKgKLd8YDr21PWl64pQvzy8BIh7W1vKH8ZWictKmNBaG9IRKlsJ667Zw==",
|
||||
"version": "0.15.15",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.15.tgz",
|
||||
"integrity": "sha512-otOKgTxNPV6gEa6PW1fHGMMF8twjnZkP0vWQhGsRISK4vN8tPfX8O9sC9Hnq3nV8axaMv4/Ff49+7mMVcFEKeA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.21.0",
|
||||
"version": "1.21.7",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -74,7 +74,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.15.11",
|
||||
"@comfyorg/litegraph": "^0.15.15",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
@click="download.triggerBrowserDownload"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:label="$t('g.copyURL')"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="copyURL"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,6 +47,7 @@ import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
@@ -49,9 +59,15 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
|
||||
293
src/components/dialog/content/signin/SignInForm.spec.ts
Normal file
293
src/components/dialog/content/signin/SignInForm.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import SignInForm from './SignInForm.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof SignInForm>
|
||||
|
||||
// Mock firebase auth modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
sendPasswordResetEmail: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock the auth composables and stores
|
||||
const mockSendPasswordReset = vi.fn()
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
sendPasswordReset: mockSendPasswordReset
|
||||
}))
|
||||
}))
|
||||
|
||||
let mockLoading = false
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
get loading() {
|
||||
return mockLoading
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock toast
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('SignInForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSendPasswordReset.mockReset()
|
||||
mockToastAdd.mockReset()
|
||||
mockLoading = false
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
props = {},
|
||||
options = {}
|
||||
): VueWrapper<ComponentInstance> => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(SignInForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, ToastService],
|
||||
components: {
|
||||
Form,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('Forgot Password Link', () => {
|
||||
it('shows disabled style when email is empty', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
|
||||
})
|
||||
|
||||
it('shows toast and focuses email input when clicked while disabled', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should show toast warning
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'warn',
|
||||
summary: enMessages.auth.login.emailPlaceholder,
|
||||
life: 5000
|
||||
})
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
|
||||
// Should NOT call sendPasswordReset
|
||||
expect(mockSendPasswordReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Spy on handleForgotPassword
|
||||
const handleForgotPasswordSpy = vi.spyOn(
|
||||
component,
|
||||
'handleForgotPassword'
|
||||
)
|
||||
|
||||
const forgotPasswordSpan = wrapper.find(
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Click the forgot password link
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
|
||||
// Should call handleForgotPassword
|
||||
expect(handleForgotPasswordSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Call onSubmit directly with valid data
|
||||
component.onSubmit({
|
||||
valid: true,
|
||||
values: { email: 'test@example.com', password: 'password123' }
|
||||
})
|
||||
|
||||
// Check emitted event
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')?.[0]).toEqual([
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not emit submit event when form is invalid', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Call onSubmit with invalid form
|
||||
component.onSubmit({ valid: false, values: {} })
|
||||
|
||||
// Should not emit submit event
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows spinner when loading', async () => {
|
||||
mockLoading = true
|
||||
|
||||
try {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(false)
|
||||
} catch (error) {
|
||||
// Fallback test - check HTML content if component rendering fails
|
||||
mockLoading = true
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.html()).toContain('p-progressspinner')
|
||||
expect(wrapper.html()).not.toContain('<button')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows button when not loading', () => {
|
||||
mockLoading = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Structure', () => {
|
||||
it('renders email input with correct attributes', () => {
|
||||
const wrapper = mountComponent()
|
||||
const emailInput = wrapper.findComponent(InputText)
|
||||
|
||||
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
|
||||
expect(emailInput.attributes('autocomplete')).toBe('email')
|
||||
expect(emailInput.attributes('name')).toBe('email')
|
||||
expect(emailInput.attributes('type')).toBe('text')
|
||||
})
|
||||
|
||||
it('renders password input with correct attributes', () => {
|
||||
const wrapper = mountComponent()
|
||||
const passwordInput = wrapper.findComponent(Password)
|
||||
|
||||
// Check props instead of attributes for Password component
|
||||
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
|
||||
// Password component passes name as prop, not attribute
|
||||
expect(passwordInput.props('name')).toBe('password')
|
||||
expect(passwordInput.props('feedback')).toBe(false)
|
||||
expect(passwordInput.props('toggleMask')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders form with correct resolver', () => {
|
||||
const wrapper = mountComponent()
|
||||
const form = wrapper.findComponent(Form)
|
||||
|
||||
expect(form.props('resolver')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus Behavior', () => {
|
||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
|
||||
// Mock getElementById
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
|
||||
// Call handleForgotPassword with valid email
|
||||
await component.handleForgotPassword('test@example.com', true)
|
||||
|
||||
// Should NOT focus email input
|
||||
expect(document.getElementById).not.toHaveBeenCalled()
|
||||
expect(mockFocus).not.toHaveBeenCalled()
|
||||
|
||||
// Should call sendPasswordReset
|
||||
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,15 +7,12 @@
|
||||
>
|
||||
<!-- Email Field -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="opacity-80 text-base font-medium mb-2"
|
||||
for="comfy-org-sign-in-email"
|
||||
>
|
||||
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
|
||||
{{ t('auth.login.emailLabel') }}
|
||||
</label>
|
||||
<InputText
|
||||
pt:root:id="comfy-org-sign-in-email"
|
||||
pt:root:autocomplete="email"
|
||||
:id="emailInputId"
|
||||
autocomplete="email"
|
||||
class="h-10"
|
||||
name="email"
|
||||
type="text"
|
||||
@@ -37,8 +34,11 @@
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<span
|
||||
class="text-muted text-base font-medium cursor-pointer"
|
||||
@click="handleForgotPassword($form.email?.value)"
|
||||
class="text-muted text-base font-medium cursor-pointer select-none"
|
||||
:class="{
|
||||
'text-link-disabled': !$form.email?.value || $form.email?.invalid
|
||||
}"
|
||||
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
</span>
|
||||
@@ -77,6 +77,7 @@ import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -87,6 +88,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const toast = useToast()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -94,14 +96,34 @@ const emit = defineEmits<{
|
||||
submit: [values: SignInData]
|
||||
}>()
|
||||
|
||||
const emailInputId = 'comfy-org-sign-in-email'
|
||||
|
||||
const onSubmit = (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignInData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgotPassword = async (email: string) => {
|
||||
if (!email) return
|
||||
const handleForgotPassword = async (
|
||||
email: string,
|
||||
isValid: boolean | undefined
|
||||
) => {
|
||||
if (!email || !isValid) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('auth.login.emailPlaceholder'),
|
||||
life: 5_000
|
||||
})
|
||||
// Focus the email input
|
||||
document.getElementById(emailInputId)?.focus?.()
|
||||
return
|
||||
}
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -150,8 +150,8 @@ const handleStopRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,8 +294,8 @@ const listenRecordingStatusChange = (value: boolean) => {
|
||||
isRecording.value = value
|
||||
|
||||
if (!value && load3DSceneRef.value?.load3d) {
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,8 +168,8 @@ const handleStopRecording = () => {
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ const listenRecordingStatusChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
hasRecording.value = true
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const resizeNodeMatchOutput = () => {
|
||||
console.log('resizeNodeMatchOutput')
|
||||
|
||||
const outputWidth = node.widgets?.find((w) => w.name === 'width')
|
||||
const outputHeight = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
|
||||
@@ -166,10 +166,11 @@ const showContextMenu = (e: CanvasPointerEvent) => {
|
||||
showSearchBox(e)
|
||||
}
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot }
|
||||
: { nodeTo: node, slotTo: fromSlot }
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
|
||||
@@ -13,13 +13,58 @@
|
||||
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
|
||||
class="sort-button"
|
||||
:icon="alphabeticalSort ? 'pi pi-sort-alpha-down' : 'pi pi-sort-alt'"
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
|
||||
:icon="selectedGroupingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="alphabeticalSort = !alphabeticalSort"
|
||||
@click="groupingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
|
||||
:icon="selectedSortingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="sortingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Popover ref="groupingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in groupingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedGroupingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectGrouping(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover ref="sortingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in sortingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedSortingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectSorting(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
@@ -62,6 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
@@ -76,16 +122,20 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import {
|
||||
ComfyNodeDefImpl,
|
||||
buildNodeDefTree,
|
||||
useNodeDefStore
|
||||
} from '@/stores/nodeDefStore'
|
||||
DEFAULT_GROUPING_ID,
|
||||
DEFAULT_SORTING_ID,
|
||||
nodeOrganizationService
|
||||
} from '@/services/nodeOrganizationService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type {
|
||||
GroupingStrategyId,
|
||||
SortingStrategyId
|
||||
} from '@/types/nodeOrganizationTypes'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
|
||||
|
||||
@@ -98,13 +148,67 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
|
||||
typeof NodeBookmarkTreeExplorer
|
||||
> | null>(null)
|
||||
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const alphabeticalSort = ref(false)
|
||||
const groupingPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const sortingPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const selectedGroupingId = useLocalStorage<GroupingStrategyId>(
|
||||
'Comfy.NodeLibrary.GroupBy',
|
||||
DEFAULT_GROUPING_ID
|
||||
)
|
||||
const selectedSortingId = useLocalStorage<SortingStrategyId>(
|
||||
'Comfy.NodeLibrary.SortBy',
|
||||
DEFAULT_SORTING_ID
|
||||
)
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const groupingOptions = computed(() =>
|
||||
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
|
||||
id: strategy.id,
|
||||
label: strategy.label,
|
||||
icon: strategy.icon
|
||||
}))
|
||||
)
|
||||
const sortingOptions = computed(() =>
|
||||
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
|
||||
id: strategy.id,
|
||||
label: strategy.label,
|
||||
icon: strategy.icon
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedGroupingIcon = computed(() =>
|
||||
nodeOrganizationService.getGroupingIcon(selectedGroupingId.value)
|
||||
)
|
||||
const selectedSortingIcon = computed(() =>
|
||||
nodeOrganizationService.getSortingIcon(selectedSortingId.value)
|
||||
)
|
||||
|
||||
const selectGrouping = (groupingId: string) => {
|
||||
selectedGroupingId.value = groupingId as GroupingStrategyId
|
||||
groupingPopover.value?.hide()
|
||||
}
|
||||
const selectSorting = (sortingId: string) => {
|
||||
selectedSortingId.value = sortingId as SortingStrategyId
|
||||
sortingPopover.value?.hide()
|
||||
}
|
||||
|
||||
const resetOrganization = () => {
|
||||
selectedGroupingId.value = DEFAULT_GROUPING_ID
|
||||
selectedSortingId.value = DEFAULT_SORTING_ID
|
||||
}
|
||||
|
||||
const root = computed(() => {
|
||||
const root = filteredRoot.value || nodeDefStore.nodeTree
|
||||
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
|
||||
// Determine which nodes to use
|
||||
const nodes =
|
||||
filteredNodeDefs.value.length > 0
|
||||
? filteredNodeDefs.value
|
||||
: nodeDefStore.visibleNodeDefs
|
||||
|
||||
// Use the service to organize nodes
|
||||
return nodeOrganizationService.organizeNodes(nodes, {
|
||||
groupBy: selectedGroupingId.value,
|
||||
sortBy: selectedSortingId.value
|
||||
})
|
||||
})
|
||||
|
||||
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
@@ -144,12 +248,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
})
|
||||
|
||||
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
|
||||
const filteredRoot = computed<TreeNode | null>(() => {
|
||||
if (!filteredNodeDefs.value.length) {
|
||||
return null
|
||||
}
|
||||
return buildNodeDefTree(filteredNodeDefs.value)
|
||||
})
|
||||
const filters: Ref<
|
||||
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
|
||||
> = ref([])
|
||||
@@ -175,8 +273,10 @@ const handleSearch = async (query: string) => {
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expandNode(filteredRoot.value)
|
||||
// Expand the search results tree
|
||||
if (filteredNodeDefs.value.length > 0) {
|
||||
expandNode(root.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onAddFilter = async (
|
||||
|
||||
@@ -50,9 +50,11 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
// Mock the useFirebaseAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined)
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -100,8 +102,7 @@ describe('CurrentUserPopover', () => {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Divider: true,
|
||||
Button: true
|
||||
Divider: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -114,6 +115,18 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('renders logout button with correct props', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[1]
|
||||
|
||||
// Check that logout button has correct props
|
||||
expect(logoutButton.props('label')).toBe('Log Out')
|
||||
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -132,12 +145,30 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('calls logout function and emits close event when logout button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[1]
|
||||
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
|
||||
// Verify logout was called
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the API pricing button (second one)
|
||||
// Find all buttons and get the API pricing button (third one now)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const apiPricingButton = buttons[1]
|
||||
const apiPricingButton = buttons[2]
|
||||
|
||||
// Click the API pricing button
|
||||
await apiPricingButton.trigger('click')
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('credits.apiPricing')"
|
||||
@@ -90,6 +102,11 @@ const handleTopUp = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authActions.logout()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenApiPricing = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
|
||||
emit('close')
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
|
||||
import {
|
||||
BaseWidget,
|
||||
type CanvasPointer,
|
||||
type LGraphNode,
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
ICustomWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
@@ -235,34 +240,61 @@ const renderPreview = (
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewWidget implements ICustomWidget {
|
||||
readonly type: 'custom'
|
||||
readonly name: string
|
||||
readonly options: IWidgetOptions<string | object>
|
||||
/** Dummy value to satisfy type requirements. */
|
||||
value: string
|
||||
y: number = 0
|
||||
/** Don't serialize the widget value. */
|
||||
serialize: boolean = false
|
||||
|
||||
constructor(name: string, options: IWidgetOptions<string | object>) {
|
||||
this.type = 'custom'
|
||||
this.name = name
|
||||
this.options = options
|
||||
this.value = ''
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
class ImagePreviewWidget extends BaseWidget {
|
||||
constructor(
|
||||
node: LGraphNode,
|
||||
_width: number,
|
||||
y: number,
|
||||
_height: number
|
||||
): void {
|
||||
renderPreview(ctx, node, y)
|
||||
name: string,
|
||||
options: IWidgetOptions<string | object>
|
||||
) {
|
||||
const widget: IBaseWidget = {
|
||||
name,
|
||||
options,
|
||||
type: 'custom',
|
||||
/** Dummy value to satisfy type requirements. */
|
||||
value: '',
|
||||
y: 0
|
||||
}
|
||||
super(widget, node)
|
||||
|
||||
// Don't serialize the widget value
|
||||
this.serialize = false
|
||||
}
|
||||
|
||||
computeLayoutSize(this: IBaseWidget) {
|
||||
override drawWidget(ctx: CanvasRenderingContext2D): void {
|
||||
renderPreview(ctx, this.node, this.y)
|
||||
}
|
||||
|
||||
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
|
||||
pointer.onDragStart = () => {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
canvas.emitBeforeChange()
|
||||
graph?.beforeChange()
|
||||
// Ensure that dragging is properly cleaned up, on success or failure.
|
||||
pointer.finally = () => {
|
||||
canvas.isDragging = false
|
||||
graph?.afterChange()
|
||||
canvas.emitAfterChange()
|
||||
}
|
||||
|
||||
canvas.processSelect(node, pointer.eDown)
|
||||
canvas.isDragging = true
|
||||
}
|
||||
|
||||
pointer.onDragEnd = (e) => {
|
||||
const { canvas } = app
|
||||
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
||||
canvas.graph?.snapToGrid(canvas.selectedItems)
|
||||
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override onClick(): void {}
|
||||
|
||||
override computeLayoutSize() {
|
||||
return {
|
||||
minHeight: 220,
|
||||
minWidth: 1
|
||||
@@ -276,7 +308,7 @@ export const useImagePreviewWidget = () => {
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(inputSpec.name, {
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
})
|
||||
)
|
||||
|
||||
@@ -28,7 +28,8 @@ export const useTextPreviewWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"imageUrl": "Image URL",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"copyURL": "Copy URL"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -415,7 +416,23 @@
|
||||
"openWorkflow": "Open workflow in local file system",
|
||||
"newBlankWorkflow": "Create a new blank workflow",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Sort Order"
|
||||
"groupBy": "Group By",
|
||||
"sortMode": "Sort Mode",
|
||||
"resetView": "Reset View to Default",
|
||||
"groupStrategies": {
|
||||
"category": "Category",
|
||||
"categoryDesc": "Group by node category",
|
||||
"module": "Module",
|
||||
"moduleDesc": "Group by module source",
|
||||
"source": "Source",
|
||||
"sourceDesc": "Group by source type (Core, Custom, API)"
|
||||
},
|
||||
"sortBy": {
|
||||
"original": "Original",
|
||||
"originalDesc": "Keep original order",
|
||||
"alphabetical": "Alphabetical",
|
||||
"alphabeticalDesc": "Sort alphabetically within groups"
|
||||
}
|
||||
},
|
||||
"modelLibrary": "Model Library",
|
||||
"downloads": "Downloads",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"control_before_generate": "control antes de generar",
|
||||
"copy": "Copiar",
|
||||
"copyToClipboard": "Copiar al portapapeles",
|
||||
"copyURL": "Copiar URL",
|
||||
"currentUser": "Usuario actual",
|
||||
"customBackground": "Fondo personalizado",
|
||||
"customize": "Personalizar",
|
||||
@@ -1064,7 +1065,23 @@
|
||||
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
|
||||
"nodeLibrary": "Biblioteca de nodos",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Orden de clasificación"
|
||||
"groupBy": "Agrupar por",
|
||||
"groupStrategies": {
|
||||
"category": "Categoría",
|
||||
"categoryDesc": "Agrupar por categoría de nodo",
|
||||
"module": "Módulo",
|
||||
"moduleDesc": "Agrupar por fuente del módulo",
|
||||
"source": "Fuente",
|
||||
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
|
||||
},
|
||||
"resetView": "Restablecer vista a la predeterminada",
|
||||
"sortBy": {
|
||||
"alphabetical": "Alfabético",
|
||||
"alphabeticalDesc": "Ordenar alfabéticamente dentro de los grupos",
|
||||
"original": "Original",
|
||||
"originalDesc": "Mantener el orden original"
|
||||
},
|
||||
"sortMode": "Modo de ordenación"
|
||||
},
|
||||
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
|
||||
"queue": "Cola",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"control_before_generate": "contrôle avant génération",
|
||||
"copy": "Copier",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"copyURL": "Copier l’URL",
|
||||
"currentUser": "Utilisateur actuel",
|
||||
"customBackground": "Arrière-plan personnalisé",
|
||||
"customize": "Personnaliser",
|
||||
@@ -1064,7 +1065,23 @@
|
||||
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
|
||||
"nodeLibrary": "Bibliothèque de nœuds",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Ordre de tri"
|
||||
"groupBy": "Grouper par",
|
||||
"groupStrategies": {
|
||||
"category": "Catégorie",
|
||||
"categoryDesc": "Grouper par catégorie de nœud",
|
||||
"module": "Module",
|
||||
"moduleDesc": "Grouper par source du module",
|
||||
"source": "Source",
|
||||
"sourceDesc": "Grouper par type de source (Core, Custom, API)"
|
||||
},
|
||||
"resetView": "Réinitialiser la vue par défaut",
|
||||
"sortBy": {
|
||||
"alphabetical": "Alphabétique",
|
||||
"alphabeticalDesc": "Trier alphabétiquement dans les groupes",
|
||||
"original": "Original",
|
||||
"originalDesc": "Conserver l'ordre d'origine"
|
||||
},
|
||||
"sortMode": "Mode de tri"
|
||||
},
|
||||
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
|
||||
"queue": "File d'attente",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"control_before_generate": "生成前の制御",
|
||||
"copy": "コピー",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"copyURL": "URLをコピー",
|
||||
"currentUser": "現在のユーザー",
|
||||
"customBackground": "カスタム背景",
|
||||
"customize": "カスタマイズ",
|
||||
@@ -1064,7 +1065,23 @@
|
||||
"newBlankWorkflow": "新しい空のワークフローを作成",
|
||||
"nodeLibrary": "ノードライブラリ",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "並び順"
|
||||
"groupBy": "グループ化",
|
||||
"groupStrategies": {
|
||||
"category": "カテゴリ",
|
||||
"categoryDesc": "ノードカテゴリでグループ化",
|
||||
"module": "モジュール",
|
||||
"moduleDesc": "モジュールソースでグループ化",
|
||||
"source": "ソース",
|
||||
"sourceDesc": "ソースタイプ(Core、Custom、API)でグループ化"
|
||||
},
|
||||
"resetView": "ビューをデフォルトにリセット",
|
||||
"sortBy": {
|
||||
"alphabetical": "アルファベット順",
|
||||
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
|
||||
"original": "元の順序",
|
||||
"originalDesc": "元の順序を維持"
|
||||
},
|
||||
"sortMode": "並び替えモード"
|
||||
},
|
||||
"openWorkflow": "ローカルでワークフローを開く",
|
||||
"queue": "キュー",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"control_before_generate": "생성 전 제어",
|
||||
"copy": "복사",
|
||||
"copyToClipboard": "클립보드에 복사",
|
||||
"copyURL": "URL 복사",
|
||||
"currentUser": "현재 사용자",
|
||||
"customBackground": "맞춤 배경",
|
||||
"customize": "사용자 정의",
|
||||
@@ -1064,7 +1065,23 @@
|
||||
"newBlankWorkflow": "새 빈 워크플로 만들기",
|
||||
"nodeLibrary": "노드 라이브러리",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "정렬 순서"
|
||||
"groupBy": "그룹 기준",
|
||||
"groupStrategies": {
|
||||
"category": "카테고리",
|
||||
"categoryDesc": "노드 카테고리별로 그룹화",
|
||||
"module": "모듈",
|
||||
"moduleDesc": "모듈 소스별로 그룹화",
|
||||
"source": "소스",
|
||||
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
|
||||
},
|
||||
"resetView": "기본 보기로 재설정",
|
||||
"sortBy": {
|
||||
"alphabetical": "알파벳순",
|
||||
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
|
||||
"original": "원본 순서",
|
||||
"originalDesc": "원래 순서를 유지"
|
||||
},
|
||||
"sortMode": "정렬 방식"
|
||||
},
|
||||
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
|
||||
"queue": "실행 대기열",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"control_before_generate": "управление до генерации",
|
||||
"copy": "Копировать",
|
||||
"copyToClipboard": "Скопировать в буфер обмена",
|
||||
"copyURL": "Скопировать URL",
|
||||
"currentUser": "Текущий пользователь",
|
||||
"customBackground": "Пользовательский фон",
|
||||
"customize": "Настроить",
|
||||
@@ -1064,7 +1065,23 @@
|
||||
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
|
||||
"nodeLibrary": "Библиотека нод",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Порядок сортировки"
|
||||
"groupBy": "Группировать по",
|
||||
"groupStrategies": {
|
||||
"category": "Категория",
|
||||
"categoryDesc": "Группировать по категории узла",
|
||||
"module": "Модуль",
|
||||
"moduleDesc": "Группировать по источнику модуля",
|
||||
"source": "Источник",
|
||||
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
|
||||
},
|
||||
"resetView": "Сбросить вид по умолчанию",
|
||||
"sortBy": {
|
||||
"alphabetical": "По алфавиту",
|
||||
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
|
||||
"original": "Оригинальный порядок",
|
||||
"originalDesc": "Сохранять исходный порядок"
|
||||
},
|
||||
"sortMode": "Режим сортировки"
|
||||
},
|
||||
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
|
||||
"queue": "Очередь",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"control_before_generate": "生成前控制",
|
||||
"copy": "复制",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"copyURL": "复制链接",
|
||||
"currentUser": "当前用户",
|
||||
"customBackground": "自定义背景",
|
||||
"customize": "自定义",
|
||||
@@ -1064,7 +1065,23 @@
|
||||
"newBlankWorkflow": "创建空白工作流",
|
||||
"nodeLibrary": "节点库",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "排序顺序"
|
||||
"groupBy": "分组方式",
|
||||
"groupStrategies": {
|
||||
"category": "类别",
|
||||
"categoryDesc": "按节点类别分组",
|
||||
"module": "模块",
|
||||
"moduleDesc": "按模块来源分组",
|
||||
"source": "来源",
|
||||
"sourceDesc": "按来源类型分组(核心,自定义,API)"
|
||||
},
|
||||
"resetView": "重置视图为默认",
|
||||
"sortBy": {
|
||||
"alphabetical": "字母顺序",
|
||||
"alphabeticalDesc": "在分组内按字母顺序排序",
|
||||
"original": "原始顺序",
|
||||
"originalDesc": "保持原始顺序"
|
||||
},
|
||||
"sortMode": "排序模式"
|
||||
},
|
||||
"openWorkflow": "在本地文件系统中打开工作流",
|
||||
"queue": "队列",
|
||||
|
||||
@@ -1074,11 +1074,11 @@ export class ComfyApp {
|
||||
try {
|
||||
// @ts-expect-error Discrepancies between zod and litegraph - in progress
|
||||
this.graph.configure(graphData)
|
||||
if (restore_view) {
|
||||
if (
|
||||
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
|
||||
graphData.extra?.ds
|
||||
) {
|
||||
if (
|
||||
restore_view &&
|
||||
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
|
||||
) {
|
||||
if (graphData.extra?.ds) {
|
||||
this.canvas.ds.offset = graphData.extra.ds.offset
|
||||
this.canvas.ds.scale = graphData.extra.ds.scale
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ChangeTracker {
|
||||
/**
|
||||
* Whether the redo/undo restoring is in progress.
|
||||
*/
|
||||
private restoringState: boolean = false
|
||||
_restoringState: boolean = false
|
||||
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
@@ -55,7 +55,7 @@ export class ChangeTracker {
|
||||
*/
|
||||
reset(state?: ComfyWorkflowJSON) {
|
||||
// Do not reset the state if we are restoring.
|
||||
if (this.restoringState) return
|
||||
if (this._restoringState) return
|
||||
|
||||
logger.debug('Reset State')
|
||||
if (state) this.activeState = clone(state)
|
||||
@@ -124,7 +124,7 @@ export class ChangeTracker {
|
||||
const prevState = source.pop()
|
||||
if (prevState) {
|
||||
target.push(this.activeState)
|
||||
this.restoringState = true
|
||||
this._restoringState = true
|
||||
try {
|
||||
await app.loadGraphData(prevState, false, false, this.workflow, {
|
||||
showMissingModelsDialog: false,
|
||||
@@ -134,7 +134,7 @@ export class ChangeTracker {
|
||||
this.activeState = prevState
|
||||
this.updateModified()
|
||||
} finally {
|
||||
this.restoringState = false
|
||||
this._restoringState = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,24 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog from a third party extension.
|
||||
* @param options - The dialog options.
|
||||
* @param options.key - The dialog key.
|
||||
* @param options.title - The dialog title.
|
||||
* @param options.headerComponent - The dialog header component.
|
||||
* @param options.footerComponent - The dialog footer component.
|
||||
* @param options.component - The dialog component.
|
||||
* @param options.props - The dialog props.
|
||||
* @returns The dialog instance and a function to close the dialog.
|
||||
*/
|
||||
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
|
||||
return {
|
||||
dialog: dialogStore.showExtensionDialog(options),
|
||||
closeDialog: () => dialogStore.closeDialog({ key: options.key })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
@@ -394,6 +412,7 @@ export const useDialogService = () => {
|
||||
showSignInDialog,
|
||||
showTopUpCreditsDialog,
|
||||
showUpdatePasswordDialog,
|
||||
showExtensionDialog,
|
||||
prompt,
|
||||
confirm
|
||||
}
|
||||
|
||||
159
src/services/nodeOrganizationService.ts
Normal file
159
src/services/nodeOrganizationService.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { ComfyNodeDefImpl, buildNodeDefTree } from '@/stores/nodeDefStore'
|
||||
import type {
|
||||
NodeGroupingStrategy,
|
||||
NodeOrganizationOptions,
|
||||
NodeSortStrategy
|
||||
} from '@/types/nodeOrganizationTypes'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
const DEFAULT_ICON = 'pi pi-sort'
|
||||
|
||||
export const DEFAULT_GROUPING_ID = 'category' as const
|
||||
export const DEFAULT_SORTING_ID = 'original' as const
|
||||
|
||||
export class NodeOrganizationService {
|
||||
private readonly groupingStrategies: NodeGroupingStrategy[] = [
|
||||
{
|
||||
id: 'category',
|
||||
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
|
||||
icon: 'pi pi-folder',
|
||||
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
|
||||
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
|
||||
const category = nodeDef.category || ''
|
||||
const categoryParts = category ? category.split('/') : []
|
||||
return [...categoryParts, nodeDef.name]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'module',
|
||||
label: 'sideToolbar.nodeLibraryTab.groupStrategies.module',
|
||||
icon: 'pi pi-box',
|
||||
description: 'sideToolbar.nodeLibraryTab.groupStrategies.moduleDesc',
|
||||
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
|
||||
const pythonModule = nodeDef.python_module || ''
|
||||
|
||||
if (!pythonModule) {
|
||||
return ['unknown_module', nodeDef.name]
|
||||
}
|
||||
|
||||
// Split the module path into components
|
||||
const parts = pythonModule.split('.')
|
||||
|
||||
// Remove common prefixes and organize
|
||||
if (parts[0] === 'nodes') {
|
||||
// Core nodes - just use 'core'
|
||||
return ['core', nodeDef.name]
|
||||
} else if (parts[0] === 'custom_nodes') {
|
||||
// Custom nodes - use the package name as the folder
|
||||
if (parts.length > 1) {
|
||||
// Return the custom node package name
|
||||
return [parts[1], nodeDef.name]
|
||||
}
|
||||
return ['custom_nodes', nodeDef.name]
|
||||
}
|
||||
|
||||
// For other modules, use the full path structure plus node name
|
||||
return [...parts, nodeDef.name]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
label: 'sideToolbar.nodeLibraryTab.groupStrategies.source',
|
||||
icon: 'pi pi-server',
|
||||
description: 'sideToolbar.nodeLibraryTab.groupStrategies.sourceDesc',
|
||||
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
|
||||
if (nodeDef.api_node) {
|
||||
return ['API nodes', nodeDef.name]
|
||||
} else if (nodeDef.nodeSource.type === NodeSourceType.Core) {
|
||||
return ['Core', nodeDef.name]
|
||||
} else if (nodeDef.nodeSource.type === NodeSourceType.CustomNodes) {
|
||||
return ['Custom nodes', nodeDef.name]
|
||||
} else {
|
||||
return ['Unknown', nodeDef.name]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
private readonly sortingStrategies: NodeSortStrategy[] = [
|
||||
{
|
||||
id: 'original',
|
||||
label: 'sideToolbar.nodeLibraryTab.sortBy.original',
|
||||
icon: 'pi pi-sort-alt',
|
||||
description: 'sideToolbar.nodeLibraryTab.sortBy.originalDesc',
|
||||
compare: () => 0
|
||||
},
|
||||
{
|
||||
id: 'alphabetical',
|
||||
label: 'sideToolbar.nodeLibraryTab.sortBy.alphabetical',
|
||||
icon: 'pi pi-sort-alpha-down',
|
||||
description: 'sideToolbar.nodeLibraryTab.sortBy.alphabeticalDesc',
|
||||
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) =>
|
||||
(a.display_name ?? '').localeCompare(b.display_name ?? '')
|
||||
}
|
||||
]
|
||||
|
||||
getGroupingStrategies(): NodeGroupingStrategy[] {
|
||||
return [...this.groupingStrategies]
|
||||
}
|
||||
|
||||
getGroupingStrategy(id: string): NodeGroupingStrategy | undefined {
|
||||
return this.groupingStrategies.find((strategy) => strategy.id === id)
|
||||
}
|
||||
|
||||
getSortingStrategies(): NodeSortStrategy[] {
|
||||
return [...this.sortingStrategies]
|
||||
}
|
||||
|
||||
getSortingStrategy(id: string): NodeSortStrategy | undefined {
|
||||
return this.sortingStrategies.find((strategy) => strategy.id === id)
|
||||
}
|
||||
|
||||
organizeNodes(
|
||||
nodes: ComfyNodeDefImpl[],
|
||||
options: NodeOrganizationOptions = {}
|
||||
): TreeNode {
|
||||
const { groupBy = DEFAULT_GROUPING_ID, sortBy = DEFAULT_SORTING_ID } =
|
||||
options
|
||||
|
||||
const groupingStrategy = this.getGroupingStrategy(groupBy)
|
||||
const sortingStrategy = this.getSortingStrategy(sortBy)
|
||||
|
||||
if (!groupingStrategy) {
|
||||
throw new Error(`Unknown grouping strategy: ${groupBy}`)
|
||||
}
|
||||
|
||||
if (!sortingStrategy) {
|
||||
throw new Error(`Unknown sorting strategy: ${sortBy}`)
|
||||
}
|
||||
|
||||
const sortedNodes =
|
||||
sortingStrategy.id !== 'original'
|
||||
? [...nodes].sort(sortingStrategy.compare)
|
||||
: nodes
|
||||
|
||||
const tree = buildNodeDefTree(sortedNodes, {
|
||||
pathExtractor: groupingStrategy.getNodePath
|
||||
})
|
||||
|
||||
if (sortBy === 'alphabetical') {
|
||||
return sortedTree(tree, { groupLeaf: true })
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
getGroupingIcon(groupingId: string): string {
|
||||
const strategy = this.getGroupingStrategy(groupingId)
|
||||
return strategy?.icon || DEFAULT_ICON
|
||||
}
|
||||
|
||||
getSortingIcon(sortingId: string): string {
|
||||
const strategy = this.getSortingStrategy(sortingId)
|
||||
return strategy?.icon || DEFAULT_ICON
|
||||
}
|
||||
}
|
||||
|
||||
export const nodeOrganizationService = new NodeOrganizationService()
|
||||
@@ -147,10 +147,33 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
return dialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog from a third party extension.
|
||||
* Explicitly keys extension dialogs with `extension-` prefix,
|
||||
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
|
||||
*/
|
||||
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
|
||||
const { key } = options
|
||||
if (!key) {
|
||||
console.error('Extension dialog key is required')
|
||||
return
|
||||
}
|
||||
|
||||
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
|
||||
|
||||
const dialog = dialogStack.value.find((d) => d.key === extKey)
|
||||
if (!dialog) return createDialog({ ...options, key: extKey })
|
||||
|
||||
dialog.visible = true
|
||||
riseDialog(dialog)
|
||||
return dialog
|
||||
}
|
||||
|
||||
return {
|
||||
dialogStack,
|
||||
riseDialog,
|
||||
showDialog,
|
||||
closeDialog
|
||||
closeDialog,
|
||||
showExtensionDialog
|
||||
}
|
||||
})
|
||||
|
||||
@@ -216,10 +216,22 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
|
||||
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
|
||||
export interface BuildNodeDefTreeOptions {
|
||||
/**
|
||||
* Custom function to extract the tree path from a node definition.
|
||||
* If not provided, uses the default path based on nodeDef.nodePath.
|
||||
*/
|
||||
pathExtractor?: (nodeDef: ComfyNodeDefImpl) => string[]
|
||||
}
|
||||
|
||||
export function buildNodeDefTree(
|
||||
nodeDefs: ComfyNodeDefImpl[],
|
||||
options: BuildNodeDefTreeOptions = {}
|
||||
): TreeNode {
|
||||
const { pathExtractor } = options
|
||||
const defaultPathExtractor = (nodeDef: ComfyNodeDefImpl) =>
|
||||
nodeDef.nodePath.split('/')
|
||||
)
|
||||
return buildTree(nodeDefs, pathExtractor || defaultPathExtractor)
|
||||
}
|
||||
|
||||
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ComfyWorkflow extends UserFile {
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
private _isModified: boolean = false
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
@@ -131,7 +131,7 @@ export interface WorkflowStore {
|
||||
activeWorkflow: LoadedComfyWorkflow | null
|
||||
isActive: (workflow: ComfyWorkflow) => boolean
|
||||
openWorkflows: ComfyWorkflow[]
|
||||
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
|
||||
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||
openWorkflowsInBackground: (paths: {
|
||||
left?: string[]
|
||||
@@ -477,7 +477,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
isSubgraphActive,
|
||||
updateActiveGraph
|
||||
}
|
||||
}) as () => WorkflowStore
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||
const bookmarks = ref<Set<string>>(new Set())
|
||||
|
||||
44
src/types/nodeOrganizationTypes.ts
Normal file
44
src/types/nodeOrganizationTypes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
export type GroupingStrategyId = 'category' | 'module' | 'source'
|
||||
export type SortingStrategyId = 'original' | 'alphabetical'
|
||||
|
||||
/**
|
||||
* Strategy for grouping nodes into tree structure
|
||||
*/
|
||||
export interface NodeGroupingStrategy {
|
||||
/** Unique identifier for the grouping strategy */
|
||||
id: string
|
||||
/** Display name for UI (i18n key) */
|
||||
label: string
|
||||
/** Icon class for the grouping option */
|
||||
icon: string
|
||||
/** Description for tooltips (i18n key) */
|
||||
description?: string
|
||||
/** Function to extract the tree path from a node definition */
|
||||
getNodePath: (nodeDef: ComfyNodeDefImpl) => string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy for sorting nodes within groups
|
||||
*/
|
||||
export interface NodeSortStrategy {
|
||||
/** Unique identifier for the sort strategy */
|
||||
id: string
|
||||
/** Display name for UI (i18n key) */
|
||||
label: string
|
||||
/** Icon class for the sort option */
|
||||
icon: string
|
||||
/** Description for tooltips (i18n key) */
|
||||
description?: string
|
||||
/** Compare function for sorting nodes within the same group */
|
||||
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for organizing nodes
|
||||
*/
|
||||
export interface NodeOrganizationOptions {
|
||||
groupBy?: string
|
||||
sortBy?: string
|
||||
}
|
||||
330
tests-ui/tests/services/nodeOrganizationService.test.ts
Normal file
330
tests-ui/tests/services/nodeOrganizationService.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
describe('nodeOrganizationService', () => {
|
||||
const createMockNodeDef = (overrides: any = {}) => {
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test/subcategory',
|
||||
python_module: 'custom_nodes.MyPackage.nodes',
|
||||
api_node: false,
|
||||
nodeSource: {
|
||||
type: NodeSourceType.CustomNodes,
|
||||
className: 'comfy-custom',
|
||||
displayText: 'Custom',
|
||||
badgeText: 'C'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
|
||||
return mockNodeDef as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
describe('getGroupingStrategies', () => {
|
||||
it('should return all grouping strategies', () => {
|
||||
const strategies = nodeOrganizationService.getGroupingStrategies()
|
||||
expect(strategies).toHaveLength(3)
|
||||
expect(strategies.map((s) => s.id)).toEqual([
|
||||
'category',
|
||||
'module',
|
||||
'source'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return immutable copy', () => {
|
||||
const strategies1 = nodeOrganizationService.getGroupingStrategies()
|
||||
const strategies2 = nodeOrganizationService.getGroupingStrategies()
|
||||
expect(strategies1).not.toBe(strategies2)
|
||||
expect(strategies1).toEqual(strategies2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGroupingStrategy', () => {
|
||||
it('should return strategy by id', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
expect(strategy).toBeDefined()
|
||||
expect(strategy?.id).toBe('category')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown id', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
|
||||
expect(strategy).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortingStrategies', () => {
|
||||
it('should return all sorting strategies', () => {
|
||||
const strategies = nodeOrganizationService.getSortingStrategies()
|
||||
expect(strategies).toHaveLength(2)
|
||||
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortingStrategy', () => {
|
||||
it('should return strategy by id', () => {
|
||||
const strategy =
|
||||
nodeOrganizationService.getSortingStrategy('alphabetical')
|
||||
expect(strategy).toBeDefined()
|
||||
expect(strategy?.id).toBe('alphabetical')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown id', () => {
|
||||
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
|
||||
expect(strategy).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('organizeNodes', () => {
|
||||
const mockNodes = [
|
||||
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
|
||||
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
|
||||
]
|
||||
|
||||
it('should organize nodes with default options', () => {
|
||||
const tree = nodeOrganizationService.organizeNodes(mockNodes)
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should organize nodes with custom grouping', () => {
|
||||
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
groupBy: 'module'
|
||||
})
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should organize nodes with custom sorting', () => {
|
||||
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
sortBy: 'alphabetical'
|
||||
})
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should throw error for unknown grouping strategy', () => {
|
||||
expect(() => {
|
||||
nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
groupBy: 'unknown'
|
||||
})
|
||||
}).toThrow('Unknown grouping strategy: unknown')
|
||||
})
|
||||
|
||||
it('should throw error for unknown sorting strategy', () => {
|
||||
expect(() => {
|
||||
nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
sortBy: 'unknown'
|
||||
})
|
||||
}).toThrow('Unknown sorting strategy: unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGroupingIcon', () => {
|
||||
it('should return strategy icon', () => {
|
||||
const icon = nodeOrganizationService.getGroupingIcon('category')
|
||||
expect(icon).toBe('pi pi-folder')
|
||||
})
|
||||
|
||||
it('should return fallback icon for unknown strategy', () => {
|
||||
const icon = nodeOrganizationService.getGroupingIcon('unknown')
|
||||
expect(icon).toBe('pi pi-sort')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortingIcon', () => {
|
||||
it('should return strategy icon', () => {
|
||||
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
|
||||
expect(icon).toBe('pi pi-sort-alpha-down')
|
||||
})
|
||||
|
||||
it('should return fallback icon for unknown strategy', () => {
|
||||
const icon = nodeOrganizationService.getSortingIcon('unknown')
|
||||
expect(icon).toBe('pi pi-sort')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grouping path extraction', () => {
|
||||
const mockNodeDef = createMockNodeDef()
|
||||
|
||||
it('category grouping should use category path', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
const path = strategy?.getNodePath(mockNodeDef)
|
||||
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
|
||||
})
|
||||
|
||||
it('module grouping should extract module path', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('module')
|
||||
const path = strategy?.getNodePath(mockNodeDef)
|
||||
expect(path).toEqual(['MyPackage', 'TestNode'])
|
||||
})
|
||||
|
||||
it('source grouping should categorize by source type', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('source')
|
||||
const path = strategy?.getNodePath(mockNodeDef)
|
||||
expect(path).toEqual(['Custom nodes', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
describe('module grouping edge cases', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('module')
|
||||
|
||||
it('should handle empty python_module', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: '' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['unknown_module', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle undefined python_module', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: undefined })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['unknown_module', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle modules with spaces in the name', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'custom_nodes.My Package With Spaces.nodes'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle modules with special characters', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'custom_nodes.my-package_v2.0.nodes'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['my-package_v2', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle deeply nested modules', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'custom_nodes.package.subpackage.module.nodes'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['package', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle core nodes module path', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['core', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle non-standard module paths', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'some.other.module.path'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('category grouping edge cases', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
|
||||
it('should handle empty category', () => {
|
||||
const nodeDef = createMockNodeDef({ category: '' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['TestNode'])
|
||||
})
|
||||
|
||||
it('should handle undefined category', () => {
|
||||
const nodeDef = createMockNodeDef({ category: undefined })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['TestNode'])
|
||||
})
|
||||
|
||||
it('should handle category with trailing slash', () => {
|
||||
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle category with multiple consecutive slashes', () => {
|
||||
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('source grouping edge cases', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('source')
|
||||
|
||||
it('should handle API nodes', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
api_node: true,
|
||||
nodeSource: {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'comfy-core',
|
||||
displayText: 'Core',
|
||||
badgeText: 'C'
|
||||
}
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['API nodes', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle unknown source type', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
nodeSource: {
|
||||
type: 'unknown' as any,
|
||||
className: 'unknown',
|
||||
displayText: 'Unknown',
|
||||
badgeText: '?'
|
||||
}
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['Unknown', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('node name edge cases', () => {
|
||||
it('should handle nodes with special characters in name', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'Test/Node:With*Special<Chars>'
|
||||
})
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual([
|
||||
'test',
|
||||
'subcategory',
|
||||
'Test/Node:With*Special<Chars>'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle nodes with very long names', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
const nodeDef = createMockNodeDef({ name: longName })
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['test', 'subcategory', longName])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting comparison', () => {
|
||||
it('original sort should keep order', () => {
|
||||
const strategy = nodeOrganizationService.getSortingStrategy('original')
|
||||
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
|
||||
const nodeB = createMockNodeDef({ display_name: 'Apple' })
|
||||
|
||||
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
|
||||
})
|
||||
|
||||
it('alphabetical sort should compare display names', () => {
|
||||
const strategy =
|
||||
nodeOrganizationService.getSortingStrategy('alphabetical')
|
||||
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
|
||||
const nodeB = createMockNodeDef({ display_name: 'Apple' })
|
||||
|
||||
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
|
||||
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
18
tsconfig.eslint.json
Normal file
18
tsconfig.eslint.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Test files should not be compiled */
|
||||
"noEmit": true,
|
||||
// "strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"*.mts",
|
||||
"*.config.js",
|
||||
"browser_tests/**/*.ts",
|
||||
"tests-ui/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import type { UserConfigExport } from 'vitest/config'
|
||||
|
||||
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
|
||||
import {
|
||||
addElementVnodeExportPlugin,
|
||||
comfyAPIPlugin,
|
||||
generateImportMapPlugin
|
||||
} from './build/plugins'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -73,40 +77,11 @@ export default defineConfig({
|
||||
: [vue()]),
|
||||
comfyAPIPlugin(IS_DEV),
|
||||
generateImportMapPlugin([
|
||||
{
|
||||
name: 'vue',
|
||||
pattern: 'vue',
|
||||
entry: './dist/vue.esm-browser.prod.js'
|
||||
},
|
||||
{
|
||||
name: 'vue-i18n',
|
||||
pattern: 'vue-i18n',
|
||||
entry: './dist/vue-i18n.esm-browser.prod.js'
|
||||
},
|
||||
{
|
||||
name: 'primevue',
|
||||
pattern: /^primevue\/?.*/,
|
||||
entry: './index.mjs',
|
||||
recursiveDependence: true
|
||||
},
|
||||
{
|
||||
name: '@primevue/themes',
|
||||
pattern: /^@primevue\/themes\/?.*/,
|
||||
entry: './index.mjs',
|
||||
recursiveDependence: true
|
||||
},
|
||||
{
|
||||
name: '@primevue/forms',
|
||||
pattern: /^@primevue\/forms\/?.*/,
|
||||
entry: './index.mjs',
|
||||
recursiveDependence: true,
|
||||
override: {
|
||||
'@primeuix/forms': {
|
||||
entry: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
|
||||
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
|
||||
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
|
||||
]),
|
||||
addElementVnodeExportPlugin(),
|
||||
|
||||
Icons({
|
||||
compiler: 'vue3'
|
||||
|
||||
Reference in New Issue
Block a user