mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +00:00
Compare commits
120 Commits
feature/qu
...
drjkl/I-ha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c48d41a88 | ||
|
|
2fc88abd59 | ||
|
|
69062c6da1 | ||
|
|
b92fa9efe8 | ||
|
|
a7c2115166 | ||
|
|
d044bed9b2 | ||
|
|
d873c8048f | ||
|
|
475d7035f7 | ||
|
|
0a5af960d5 | ||
|
|
eb6bf91e20 | ||
|
|
cdd8105b1a | ||
|
|
422227d2fc | ||
|
|
53013d04ef | ||
|
|
10e9bc2f8d | ||
|
|
f7b835e6a5 | ||
|
|
7f30d6b6a5 | ||
|
|
da56c9e554 | ||
|
|
79063edf54 | ||
|
|
d4c40f5255 | ||
|
|
1e1d5c8308 | ||
|
|
18f3877cab | ||
|
|
e411a104f4 | ||
|
|
19a724710c | ||
|
|
21445f1faf | ||
|
|
6a1dcf8a1e | ||
|
|
9ecbb3af27 | ||
|
|
a13d28cc16 | ||
|
|
581452d312 | ||
|
|
9dde4e7bc7 | ||
|
|
0288ea5b39 | ||
|
|
061e96e488 | ||
|
|
2ed5618331 | ||
|
|
c4c65070e9 | ||
|
|
7ad917343e | ||
|
|
25cc481e08 | ||
|
|
8ff385fc4d | ||
|
|
6ae2bc0e2a | ||
|
|
ae8940c0c0 | ||
|
|
6465c48423 | ||
|
|
6db94117b8 | ||
|
|
58d82b789b | ||
|
|
da43303ecf | ||
|
|
e00e2848a8 | ||
|
|
f2d5c415ae | ||
|
|
0edd5b17e7 | ||
|
|
8376db4813 | ||
|
|
d9e9d68230 | ||
|
|
06b96b13b9 | ||
|
|
05c5d08acb | ||
|
|
0aec287e83 | ||
|
|
a2fdb2fcb2 | ||
|
|
20b16009c7 | ||
|
|
f13a88a64e | ||
|
|
31a1e14d2c | ||
|
|
924db2b815 | ||
|
|
e0090a5a4b | ||
|
|
620ad24796 | ||
|
|
ea0e6b9d2a | ||
|
|
fd78ec3077 | ||
|
|
d02dfca1de | ||
|
|
81606328b8 | ||
|
|
ee1d61b71b | ||
|
|
f7b50067a6 | ||
|
|
00aa420049 | ||
|
|
eb883c507d | ||
|
|
4aa40a560c | ||
|
|
f998fc0aab | ||
|
|
c03cd17c87 | ||
|
|
62108147c3 | ||
|
|
94523defc1 | ||
|
|
a4df681fb5 | ||
|
|
5f3fd7f7be | ||
|
|
7c18a5a185 | ||
|
|
99bc407a1d | ||
|
|
e60a475d8e | ||
|
|
b56a028635 | ||
|
|
cc0bdf22e7 | ||
|
|
82b4be3988 | ||
|
|
5540d22f64 | ||
|
|
a3cef10edf | ||
|
|
deb9603b30 | ||
|
|
82b83460fc | ||
|
|
cd3b1c5afe | ||
|
|
aa7e59deaa | ||
|
|
4fc40e0039 | ||
|
|
ac5c84b514 | ||
|
|
0b1c2217e5 | ||
|
|
2269275bd9 | ||
|
|
37f8b73cfd | ||
|
|
7741a9bbb2 | ||
|
|
19bcbce4a6 | ||
|
|
53fa5c22c1 | ||
|
|
8d53bbf263 | ||
|
|
0d274344a1 | ||
|
|
e6ec331a71 | ||
|
|
8c38d8a5de | ||
|
|
9f525bb540 | ||
|
|
25a25efbb2 | ||
|
|
933fc35f02 | ||
|
|
27a5701636 | ||
|
|
af48faee96 | ||
|
|
4c5e213cd5 | ||
|
|
a98d01b2a2 | ||
|
|
8fe2ed3efe | ||
|
|
e981c38df7 | ||
|
|
a516c1cb45 | ||
|
|
7821b69ef7 | ||
|
|
6a420f2896 | ||
|
|
2d20de7e1f | ||
|
|
4e9dc97ad5 | ||
|
|
bb6ad22003 | ||
|
|
febc1951d4 | ||
|
|
d012682dec | ||
|
|
826f4b1d80 | ||
|
|
832b34c381 | ||
|
|
74dbad2404 | ||
|
|
aaf33ce6ad | ||
|
|
0bff3fe7ea | ||
|
|
cd70d7a576 | ||
|
|
e974f88e86 |
132
.oxlintrc.json
132
.oxlintrc.json
@@ -21,6 +21,7 @@
|
||||
"eslint",
|
||||
"import",
|
||||
"oxc",
|
||||
"promise",
|
||||
"typescript",
|
||||
"unicorn",
|
||||
"vitest",
|
||||
@@ -28,6 +29,12 @@
|
||||
],
|
||||
"rules": {
|
||||
"no-async-promise-executor": "off",
|
||||
"no-else-return": [
|
||||
"error",
|
||||
{
|
||||
"allowElseIf": false
|
||||
}
|
||||
],
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
@@ -35,8 +42,29 @@
|
||||
}
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"eqeqeq": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"null": "ignore"
|
||||
}
|
||||
],
|
||||
"func-style": [
|
||||
"error",
|
||||
"declaration",
|
||||
{
|
||||
"allowArrowFunctions": true
|
||||
}
|
||||
],
|
||||
"no-eval": "off",
|
||||
"no-new-func": "error",
|
||||
// TODO: Enable and fix 104 violations
|
||||
"no-param-reassign": "off",
|
||||
"no-redeclare": "error",
|
||||
"no-return-assign": ["error", "always"],
|
||||
"no-throw-literal": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-var": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -64,15 +92,66 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unneeded-ternary": [
|
||||
"error",
|
||||
{
|
||||
"defaultAssignment": false
|
||||
}
|
||||
],
|
||||
"no-useless-call": "error",
|
||||
"no-useless-concat": "error",
|
||||
"prefer-const": "error",
|
||||
// TODO: Enable and fix 581 violations
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-object-has-own": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"promise/no-nesting": "error",
|
||||
"promise/param-names": "error",
|
||||
// TODO: Enable and fix 76 violations
|
||||
"promise/prefer-await-to-callbacks": "off",
|
||||
// TODO: Enable and fix 91 violations
|
||||
"promise/prefer-await-to-then": "off",
|
||||
"promise/prefer-catch": "error",
|
||||
"preserve-caught-error": "error",
|
||||
"yoda": [
|
||||
"error",
|
||||
"never",
|
||||
{
|
||||
"exceptRange": true
|
||||
}
|
||||
],
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
"no-useless-rename": "off",
|
||||
"operator-assignment": ["error", "always"],
|
||||
"import/default": "error",
|
||||
"import/export": "error",
|
||||
"import/first": ["error", "absolute-first"],
|
||||
"import/namespace": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"vitest/consistent-each-for": [
|
||||
"error",
|
||||
{
|
||||
"test": "for",
|
||||
"describe": "for"
|
||||
}
|
||||
],
|
||||
"vitest/consistent-test-filename": [
|
||||
"error",
|
||||
{
|
||||
"pattern": ".*\\.test\\.ts$"
|
||||
}
|
||||
],
|
||||
"vitest/consistent-vitest-vi": "error",
|
||||
"vitest/warn-todo": "warn",
|
||||
"vitest/hoisted-apis-on-top": "error",
|
||||
"vitest/no-conditional-tests": "error",
|
||||
"vitest/prefer-describe-function-title": "error",
|
||||
"jest/expect-expect": "off",
|
||||
"jest/no-conditional-expect": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
@@ -82,11 +161,55 @@
|
||||
"typescript/no-unnecessary-parameter-property-assignment": "off",
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/catch-error-name": [
|
||||
"error",
|
||||
{
|
||||
"ignore": ["^error\\w+$"]
|
||||
}
|
||||
],
|
||||
// TODO: Enable and fix 147 violations
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/error-message": "error",
|
||||
"unicorn/no-abusive-eslint-disable": "error",
|
||||
// TODO: Enable and fix 165 violations
|
||||
"unicorn/no-array-for-each": "off",
|
||||
"unicorn/no-immediate-mutation": "error",
|
||||
"unicorn/no-instanceof-array": "error",
|
||||
"unicorn/no-length-as-slice-end": "error",
|
||||
"unicorn/no-lonely-if": "error",
|
||||
"unicorn/no-negation-in-equality-check": "error",
|
||||
"unicorn/no-typeof-undefined": "error",
|
||||
"unicorn/prefer-math-min-max": "error",
|
||||
"unicorn/prefer-array-flat-map": "error",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/prefer-add-event-listener": "error",
|
||||
"unicorn/prefer-array-find": "error",
|
||||
"unicorn/no-useless-undefined": [
|
||||
"error",
|
||||
{
|
||||
"checkArguments": false,
|
||||
"checkArrowFunctionBody": false
|
||||
}
|
||||
],
|
||||
"unicorn/prefer-classlist-toggle": "error",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-this-assignment": "error",
|
||||
"unicorn/no-useless-collection-argument": "error",
|
||||
"unicorn/no-useless-switch-case": "error",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
"unicorn/no-useless-spread": "off",
|
||||
"unicorn/prefer-optional-catch-binding": "error",
|
||||
"unicorn/prefer-prototype-methods": "error",
|
||||
"unicorn/prefer-query-selector": "error",
|
||||
"unicorn/prefer-spread": "error",
|
||||
"unicorn/prefer-regexp-test": "error",
|
||||
"unicorn/prefer-set-has": "error",
|
||||
"unicorn/prefer-string-replace-all": "error",
|
||||
"unicorn/prefer-string-slice": "error",
|
||||
"unicorn/prefer-string-trim-start-end": "error",
|
||||
"unicorn/prefer-type-error": "error",
|
||||
"unicorn/throw-new-error": "error",
|
||||
"typescript/await-thenable": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
@@ -96,6 +219,12 @@
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
// TODO: Enable and fix 372 violations (use { "ignoreConditionalTests": true })
|
||||
"typescript/prefer-nullish-coalescing": "off",
|
||||
// TODO: Enable and fix violations
|
||||
"typescript/prefer-optional-chain": "off",
|
||||
"typescript/prefer-ts-expect-error": "error",
|
||||
"vue/define-props-destructuring": "error",
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
@@ -114,7 +243,8 @@
|
||||
"no-control-regex": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"unicorn/no-empty-file": "error"
|
||||
"unicorn/no-empty-file": "error",
|
||||
"vitest/consistent-test-filename": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -98,12 +98,10 @@ const config: StorybookConfig = {
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
experimental: {
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true
|
||||
keepNames: true,
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
|
||||
@@ -298,7 +298,10 @@ test.describe('Settings', () => {
|
||||
await input.press('Alt+n')
|
||||
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Keybinding.NewBindings'
|
||||
(req) =>
|
||||
req.url().includes('/api/settings') &&
|
||||
!req.url().includes('/api/settings/') &&
|
||||
req.method() === 'POST'
|
||||
)
|
||||
|
||||
// Save keybinding
|
||||
|
||||
@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
@@ -12,6 +12,18 @@ This guide covers patterns and examples for testing Vue components in the ComfyU
|
||||
6. [Asynchronous Component Testing](#asynchronous-component-testing)
|
||||
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
|
||||
|
||||
## Describe Block Naming
|
||||
|
||||
Use `Component.__name ?? 'ComponentName'` for the top-level `describe` title. This passes the function reference (satisfying the `prefer-describe-function-title` lint rule) while providing a readable fallback:
|
||||
|
||||
```typescript
|
||||
import MyComponent from './MyComponent.vue'
|
||||
|
||||
describe(MyComponent.__name ?? 'MyComponent', () => {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
## Basic Component Testing
|
||||
|
||||
Basic approach to testing a component's rendering and structure:
|
||||
@@ -21,7 +33,7 @@ Basic approach to testing a component's rendering and structure:
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
describe('SidebarIcon', () => {
|
||||
describe(SidebarIcon.__name ?? 'SidebarIcon', () => {
|
||||
const exampleProps = {
|
||||
icon: 'pi pi-cog',
|
||||
selected: false
|
||||
|
||||
@@ -138,6 +138,10 @@ export default defineConfig([
|
||||
'import-x/no-useless-path-segments': 'error',
|
||||
'import-x/no-relative-packages': 'error',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'vue/return-in-computed-property': [
|
||||
'error',
|
||||
{ treatUndefinedAsUnspecified: false }
|
||||
],
|
||||
'vue/no-v-html': 'off',
|
||||
// Prohibit dark-theme: and dark: prefixes
|
||||
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],
|
||||
|
||||
7
global.d.ts
vendored
7
global.d.ts
vendored
@@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
@@ -37,6 +42,8 @@ interface Window {
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
42
index.html
42
index.html
@@ -35,18 +35,6 @@
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#vue-app:has(#loading-logo) {
|
||||
display: contents;
|
||||
color: var(--fg-color);
|
||||
& #loading-logo {
|
||||
place-self: center;
|
||||
font-size: clamp(2px, 1vw, 6px);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
border-radius: 20ch;
|
||||
}
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -65,36 +53,6 @@
|
||||
<body class="litegraph grid">
|
||||
<div id="vue-app">
|
||||
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
||||
<svg
|
||||
width="520"
|
||||
height="520"
|
||||
viewBox="0 0 520 520"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="loading-logo"
|
||||
>
|
||||
<mask
|
||||
id="mask0_227_285"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="520"
|
||||
height="520"
|
||||
>
|
||||
<path
|
||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
||||
fill="#EEFF30"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
||||
<path
|
||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
||||
fill="#F0FF41"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.10",
|
||||
"version": "1.40.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.8"
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1111
pnpm-lock.yaml
generated
1111
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -69,9 +69,9 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
oxfmt: ^0.31.0
|
||||
oxlint: ^1.46.0
|
||||
oxlint-tsgolint: ^0.11.5
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
@@ -92,7 +92,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vite: ^8.0.0-beta.8
|
||||
vite: 8.0.0-beta.13
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
@@ -33,6 +33,7 @@ fi
|
||||
|
||||
EXCLUDE_PATTERNS=(
|
||||
'**/tsconfig*.json'
|
||||
'.oxlintrc.json'
|
||||
)
|
||||
|
||||
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then
|
||||
|
||||
@@ -16,12 +16,12 @@ import { computed, onMounted } from 'vue'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
|
||||
@@ -46,7 +49,7 @@ describe('downloadUtil', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('downloadFile', () => {
|
||||
describe(downloadFile, () => {
|
||||
it('should create and trigger download with basic URL', () => {
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
|
||||
expect(mockLink.download).toBe('user-friendly.png')
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||
)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('中文.png')
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl, 'my-fallback.png')
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('my-fallback.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe(extractFilenameFromContentDisposition, () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty header', () => {
|
||||
expect(extractFilenameFromContentDisposition('')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts filename from simple quoted format', () => {
|
||||
const header = 'attachment; filename="test-file.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from unquoted format', () => {
|
||||
const header = 'attachment; filename=test-file.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from RFC 5987 format', () => {
|
||||
const header = "attachment; filename*=UTF-8''test%20file.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers RFC 5987 format over simple format', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'preferred.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles unicode characters in RFC 5987 format', () => {
|
||||
const header =
|
||||
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
|
||||
})
|
||||
|
||||
it('falls back to simple format when RFC 5987 decoding fails', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
|
||||
})
|
||||
|
||||
it('handles header with only attachment disposition', () => {
|
||||
const header = 'attachment'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles case-insensitive filename parameter', () => {
|
||||
const header = 'attachment; FILENAME="test.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from Content-Disposition header
|
||||
* Handles both simple format: attachment; filename="name.png"
|
||||
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
|
||||
* @param header - The Content-Disposition header value
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
export function extractFilenameFromContentDisposition(
|
||||
header: string | null
|
||||
): string | null {
|
||||
if (!header) return null
|
||||
|
||||
// Try RFC 5987 extended format first (filename*=UTF-8''...)
|
||||
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (extendedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(extendedMatch[1])
|
||||
} catch {
|
||||
// Fall through to simple format
|
||||
}
|
||||
}
|
||||
|
||||
// Try simple quoted format: filename="..."
|
||||
const quotedMatch = header.match(/filename="([^"]+)"/i)
|
||||
if (quotedMatch?.[1]) {
|
||||
return quotedMatch[1]
|
||||
}
|
||||
|
||||
// Try unquoted format: filename=...
|
||||
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
|
||||
if (unquotedMatch?.[1]) {
|
||||
return unquotedMatch[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
href: string,
|
||||
filename: string
|
||||
fallbackFilename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const headerFilename =
|
||||
extractFilenameFromContentDisposition(contentDisposition)
|
||||
|
||||
const blob = await response.blob()
|
||||
downloadBlob(filename, blob)
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
@@ -172,19 +172,17 @@ const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const firstPanelStyle = computed(() =>
|
||||
sidebarLocation.value === 'left'
|
||||
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
: undefined
|
||||
)
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const lastPanelStyle = computed(() =>
|
||||
sidebarLocation.value === 'right'
|
||||
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -108,7 +108,7 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(createJob(id, status))
|
||||
}
|
||||
|
||||
describe('TopMenuSection', () => {
|
||||
describe(TopMenuSection.__name ?? 'TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
@@ -242,7 +242,7 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
return undefined
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -287,9 +287,8 @@ const openCustomNodeManager = async () => {
|
||||
} catch (error) {
|
||||
try {
|
||||
toastErrorHandler(error)
|
||||
} catch (toastError) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error(toastError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ vi.mock('primevue/progressspinner', () => ({
|
||||
default: { template: '<div class="progress-spinner" />' }
|
||||
}))
|
||||
|
||||
describe('WorkspaceAuthGate', () => {
|
||||
describe(WorkspaceAuthGate.__name ?? 'WorkspaceAuthGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -51,7 +51,7 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
describe('EssentialsPanel', () => {
|
||||
describe(EssentialsPanel.__name ?? 'EssentialsPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ShortcutsList', () => {
|
||||
describe(ShortcutsList.__name ?? 'ShortcutsList', () => {
|
||||
const mockCommands: ComfyCommandImpl[] = [
|
||||
{
|
||||
id: 'Workflow.New',
|
||||
|
||||
@@ -106,7 +106,7 @@ const mountBaseTerminal = () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('BaseTerminal', () => {
|
||||
describe(BaseTerminal.__name ?? 'BaseTerminal', () => {
|
||||
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -62,8 +62,8 @@ const terminalCreated = (
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadLogEntries()
|
||||
} catch (err) {
|
||||
console.error('Error loading logs', err)
|
||||
} catch (error) {
|
||||
console.error('Error loading logs', error)
|
||||
// On older backends the endpoints won't exist
|
||||
errorMessage.value =
|
||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
|
||||
|
||||
@@ -78,9 +78,7 @@ interface Props {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
const { item, isActive = false } = defineProps<Props>()
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
@@ -103,7 +101,7 @@ const rename = async (
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
props.item.updateTitle?.(newName)
|
||||
item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
@@ -127,13 +125,13 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const isRoot = item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
return item.label
|
||||
})
|
||||
|
||||
const startRename = async () => {
|
||||
@@ -145,7 +143,7 @@ const startRename = async () => {
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
itemLabel.value = item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (props.isActive) {
|
||||
if (isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
item.command?.({ item, originalEvent: event })
|
||||
}
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
} else if (isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
@@ -180,7 +178,7 @@ const handleClick = (event: MouseEvent) => {
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
await rename(itemLabel.value, item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
|
||||
@@ -7,123 +7,128 @@ import { createApp, nextTick } from 'vue'
|
||||
|
||||
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
|
||||
|
||||
describe('ColorCustomizationSelector', () => {
|
||||
const colorOptions = [
|
||||
{ name: 'Blue', value: '#0d6efd' },
|
||||
{ name: 'Green', value: '#28a745' }
|
||||
]
|
||||
describe(
|
||||
ColorCustomizationSelector.__name ?? 'ColorCustomizationSelector',
|
||||
() => {
|
||||
const colorOptions = [
|
||||
{ name: 'Blue', value: '#0d6efd' },
|
||||
{ name: 'Green', value: '#28a745' }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup PrimeVue
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
beforeEach(() => {
|
||||
// Setup PrimeVue
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(ColorCustomizationSelector, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { SelectButton, ColorPicker }
|
||||
},
|
||||
props: {
|
||||
modelValue: null,
|
||||
colorOptions,
|
||||
...props
|
||||
}
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(ColorCustomizationSelector, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { SelectButton, ColorPicker }
|
||||
},
|
||||
props: {
|
||||
modelValue: null,
|
||||
colorOptions,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders predefined color options and custom option', () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
expect(selectButton.props('options')).toHaveLength(
|
||||
colorOptions.length + 1
|
||||
)
|
||||
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
|
||||
})
|
||||
|
||||
it('initializes with predefined color when provided', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: '#0d6efd'
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
expect(selectButton.props('modelValue')).toEqual({
|
||||
name: 'Blue',
|
||||
value: '#0d6efd'
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes with custom color when non-predefined color provided', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: '#123456'
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
|
||||
expect(selectButton.props('modelValue').name).toBe('_custom')
|
||||
expect(colorPicker.props('modelValue')).toBe('123456')
|
||||
})
|
||||
|
||||
it('shows color picker when custom option is selected', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
// Select custom option
|
||||
await selectButton.setValue({ name: '_custom', value: '' })
|
||||
|
||||
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update when predefined color is selected', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
await selectButton.setValue(colorOptions[0])
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
|
||||
})
|
||||
|
||||
it('emits update when custom color is changed', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
// Select custom option
|
||||
await selectButton.setValue({ name: '_custom', value: '' })
|
||||
|
||||
// Change custom color
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
await colorPicker.setValue('ff0000')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
|
||||
})
|
||||
|
||||
it('inherits color from previous selection when switching to custom', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
// First select a predefined color
|
||||
await selectButton.setValue(colorOptions[0])
|
||||
|
||||
// Then switch to custom
|
||||
await selectButton.setValue({ name: '_custom', value: '' })
|
||||
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.props('modelValue')).toBe('0d6efd')
|
||||
})
|
||||
|
||||
it('handles null modelValue correctly', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
expect(selectButton.props('modelValue')).toEqual({
|
||||
name: '_custom',
|
||||
value: ''
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('renders predefined color options and custom option', () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
|
||||
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
|
||||
})
|
||||
|
||||
it('initializes with predefined color when provided', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: '#0d6efd'
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
expect(selectButton.props('modelValue')).toEqual({
|
||||
name: 'Blue',
|
||||
value: '#0d6efd'
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes with custom color when non-predefined color provided', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: '#123456'
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
|
||||
expect(selectButton.props('modelValue').name).toBe('_custom')
|
||||
expect(colorPicker.props('modelValue')).toBe('123456')
|
||||
})
|
||||
|
||||
it('shows color picker when custom option is selected', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
// Select custom option
|
||||
await selectButton.setValue({ name: '_custom', value: '' })
|
||||
|
||||
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update when predefined color is selected', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
await selectButton.setValue(colorOptions[0])
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
|
||||
})
|
||||
|
||||
it('emits update when custom color is changed', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
// Select custom option
|
||||
await selectButton.setValue({ name: '_custom', value: '' })
|
||||
|
||||
// Change custom color
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
await colorPicker.setValue('ff0000')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
|
||||
})
|
||||
|
||||
it('inherits color from previous selection when switching to custom', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
|
||||
// First select a predefined color
|
||||
await selectButton.setValue(colorOptions[0])
|
||||
|
||||
// Then switch to custom
|
||||
await selectButton.setValue({ name: '_custom', value: '' })
|
||||
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.props('modelValue')).toBe('0d6efd')
|
||||
})
|
||||
|
||||
it('handles null modelValue correctly', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const selectButton = wrapper.findComponent(SelectButton)
|
||||
expect(selectButton.props('modelValue')).toEqual({
|
||||
name: '_custom',
|
||||
value: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const { renderFunction } = defineProps<{
|
||||
renderFunction: () => HTMLElement
|
||||
}>()
|
||||
|
||||
@@ -14,12 +14,12 @@ const container = ref<HTMLElement | null>(null)
|
||||
function renderContent() {
|
||||
if (container.value) {
|
||||
container.value.innerHTML = ''
|
||||
const element = props.renderFunction()
|
||||
const element = renderFunction()
|
||||
container.value.appendChild(element)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(renderContent)
|
||||
|
||||
watch(() => props.renderFunction, renderContent)
|
||||
watch(() => renderFunction, renderContent)
|
||||
</script>
|
||||
|
||||
@@ -52,7 +52,7 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
const { modelValue, initialIcon, initialColor } = defineProps<{
|
||||
modelValue: boolean
|
||||
initialIcon?: string
|
||||
initialColor?: string
|
||||
@@ -64,7 +64,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
get: () => modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
@@ -96,17 +96,13 @@ const defaultIcon = iconOptions.find(
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
|
||||
|
||||
const resetCustomization = () => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ||
|
||||
defaultIcon
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
iconOptions.find((option) => option.value === initialIcon) || defaultIcon
|
||||
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
const confirmCustomization = () => {
|
||||
@@ -119,7 +115,7 @@ const closeDialog = () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => modelValue,
|
||||
(newValue: boolean) => {
|
||||
if (newValue) {
|
||||
resetCustomization()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>
|
||||
{{ formatValue(props.device[col.field], col.field) }}
|
||||
{{ formatValue(device[col.field], col.field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@
|
||||
import type { DeviceStats } from '@/schemas/apiSchema'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { device } = defineProps<{
|
||||
device: DeviceStats
|
||||
}>()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createApp } from 'vue'
|
||||
|
||||
import EditableText from './EditableText.vue'
|
||||
|
||||
describe('EditableText', () => {
|
||||
describe(EditableText.__name ?? 'EditableText', () => {
|
||||
beforeAll(() => {
|
||||
// Create a Vue app instance for PrimeVue
|
||||
const app = createApp({})
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
|
||||
<div class="file-info">
|
||||
<div class="file-details">
|
||||
<span class="file-type" :title="hint">{{ label }}</span>
|
||||
<span class="file-type" :title="displayHint">{{ displayLabel }}</span>
|
||||
</div>
|
||||
<div v-if="props.error" class="file-error">
|
||||
{{ props.error }}
|
||||
<div v-if="error" class="file-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
:disabled="!!error"
|
||||
@click="triggerDownload"
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="(status === null || status === 'error') && !!props.url"
|
||||
v-if="(status === null || status === 'error') && !!url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="copyURL"
|
||||
@@ -53,7 +53,7 @@
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
:disabled="!!error"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="pi pi-pause-circle" />
|
||||
@@ -66,7 +66,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
:disabled="!!props.error"
|
||||
:disabled="!!error"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="pi pi-play-circle" />
|
||||
@@ -78,7 +78,7 @@
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
:disabled="!!props.error"
|
||||
:disabled="!!error"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="pi pi-times-circle" />
|
||||
@@ -98,7 +98,7 @@ import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { url, hint, label, error } = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
@@ -106,9 +106,9 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const displayLabel = computed(() => label || url.split('/').pop())
|
||||
const displayHint = computed(() => hint || url)
|
||||
const download = useDownload(url)
|
||||
const downloadProgress = ref<number>(0)
|
||||
const status = ref<string | null>(null)
|
||||
const fileSize = computed(() =>
|
||||
@@ -117,10 +117,10 @@ const fileSize = computed(() =>
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const [savePath, filename] = props.label.split('/')
|
||||
const [savePath, filename] = label.split('/')
|
||||
|
||||
electronDownloadStore.$subscribe((_, { downloads }) => {
|
||||
const download = downloads.find((download) => props.url === download.url)
|
||||
const download = downloads.find((download) => url === download.url)
|
||||
|
||||
if (download) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -132,17 +132,17 @@ electronDownloadStore.$subscribe((_, { downloads }) => {
|
||||
|
||||
const triggerDownload = async () => {
|
||||
await electronDownloadStore.start({
|
||||
url: props.url,
|
||||
url,
|
||||
savePath: savePath.trim(),
|
||||
filename: filename.trim()
|
||||
})
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
|
||||
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
|
||||
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
|
||||
const triggerCancelDownload = () => electronDownloadStore.cancel(url)
|
||||
const triggerPauseDownload = () => electronDownloadStore.pause(url)
|
||||
const triggerResumeDownload = () => electronDownloadStore.resume(url)
|
||||
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
await copyToClipboard(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el)
|
||||
mountCustomExtension(
|
||||
props.extension as CustomExtension,
|
||||
el as HTMLElement
|
||||
)
|
||||
mountCustomExtension(extension as CustomExtension, el as HTMLElement)
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -19,17 +16,17 @@ import { onBeforeUnmount } from 'vue'
|
||||
|
||||
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
const { extension } = defineProps<{
|
||||
extension: VueExtension | CustomExtension
|
||||
}>()
|
||||
|
||||
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
|
||||
extension.render(el)
|
||||
const mountCustomExtension = (ext: CustomExtension, el: HTMLElement) => {
|
||||
ext.render(el)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.extension.type === 'custom' && props.extension.destroy) {
|
||||
props.extension.destroy()
|
||||
if (extension.type === 'custom' && extension.destroy) {
|
||||
extension.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,35 +3,35 @@
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<div>
|
||||
<span :title="hint">{{ label }}</span>
|
||||
<span :title="displayHint">{{ displayLabel }}</span>
|
||||
</div>
|
||||
<Message
|
||||
v-if="props.error"
|
||||
v-if="error"
|
||||
severity="error"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="my-2 h-min max-w-xs px-1"
|
||||
:title="props.error"
|
||||
:title="error"
|
||||
:pt="{
|
||||
text: { class: 'overflow-hidden text-ellipsis' }
|
||||
}"
|
||||
>
|
||||
{{ props.error }}
|
||||
{{ error }}
|
||||
</Message>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="!!props.error"
|
||||
:title="props.url"
|
||||
:disabled="!!error"
|
||||
:title="url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
>
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
|
||||
<Button variant="secondary" :disabled="!!error" @click="copyURL">
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -47,22 +47,22 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { url, hint, label, error } = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const displayLabel = computed(() => label || url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const displayHint = computed(() => hint || url)
|
||||
const download = useDownload(url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
await copyToClipboard(url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
@@ -10,7 +10,7 @@ import ColorPicker from 'primevue/colorpicker'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
defineProps<{
|
||||
const { label } = defineProps<{
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
const { modelValue } = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
@@ -64,9 +64,9 @@ const handleFileUpload = (event: Event) => {
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
reader.addEventListener('load', (e) => {
|
||||
emit('update:modelValue', e.target?.result as string)
|
||||
}
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span
|
||||
:id="`${props.id}-label`"
|
||||
class="text-muted"
|
||||
:class="props.labelClass"
|
||||
>
|
||||
<span :id="`${id}-label`" class="text-muted" :class="labelClass">
|
||||
<slot name="name-prefix" />
|
||||
{{ props.item.name }}
|
||||
{{ item.name }}
|
||||
<i
|
||||
v-if="props.item.tooltip"
|
||||
v-tooltip="props.item.tooltip"
|
||||
v-if="item.tooltip"
|
||||
v-tooltip="item.tooltip"
|
||||
class="pi pi-info-circle bg-transparent"
|
||||
/>
|
||||
<slot name="name-suffix" />
|
||||
@@ -19,11 +15,11 @@
|
||||
</div>
|
||||
<div class="form-input flex justify-end">
|
||||
<component
|
||||
:is="markRaw(getFormComponent(props.item))"
|
||||
:id="props.id"
|
||||
:is="markRaw(getFormComponent(item))"
|
||||
:id="id"
|
||||
v-model:model-value="formValue"
|
||||
:aria-labelledby="`${props.id}-label`"
|
||||
v-bind="getFormAttrs(props.item)"
|
||||
:aria-labelledby="`${id}-label`"
|
||||
v-bind="getFormAttrs(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,35 +44,37 @@ import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import type { FormItem } from '@/platform/settings/types'
|
||||
|
||||
const formValue = defineModel<unknown>('formValue')
|
||||
const props = defineProps<{
|
||||
const { item, id, labelClass } = defineProps<{
|
||||
item: FormItem
|
||||
id?: string
|
||||
labelClass?: string | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
function getFormAttrs(item: FormItem) {
|
||||
const attrs = { ...(item.attrs || {}) }
|
||||
const inputType = item.type
|
||||
function getFormAttrs(formItem: FormItem) {
|
||||
const attrs = { ...(formItem.attrs || {}) }
|
||||
const inputType = formItem.type
|
||||
if (typeof inputType === 'function') {
|
||||
attrs['renderFunction'] = () =>
|
||||
inputType(
|
||||
props.item.name,
|
||||
(v: unknown) => (formValue.value = v),
|
||||
formItem.name,
|
||||
(v: unknown) => {
|
||||
formValue.value = v
|
||||
},
|
||||
formValue.value,
|
||||
item.attrs
|
||||
formItem.attrs
|
||||
)
|
||||
}
|
||||
switch (item.type) {
|
||||
switch (formItem.type) {
|
||||
case 'combo':
|
||||
case 'radio':
|
||||
attrs['options'] =
|
||||
typeof item.options === 'function'
|
||||
typeof formItem.options === 'function'
|
||||
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
|
||||
// (value) => [string | {text: string, value: string}]
|
||||
item.options(formValue.value)
|
||||
: item.options
|
||||
formItem.options(formValue.value)
|
||||
: formItem.options
|
||||
|
||||
if (typeof item.options?.[0] !== 'string') {
|
||||
if (typeof formItem.options?.[0] !== 'string') {
|
||||
attrs['optionLabel'] = 'text'
|
||||
attrs['optionValue'] = 'value'
|
||||
}
|
||||
@@ -85,11 +83,11 @@ function getFormAttrs(item: FormItem) {
|
||||
return attrs
|
||||
}
|
||||
|
||||
function getFormComponent(item: FormItem): Component {
|
||||
if (typeof item.type === 'function') {
|
||||
function getFormComponent(formItem: FormItem): Component {
|
||||
if (typeof formItem.type === 'function') {
|
||||
return CustomFormValue
|
||||
}
|
||||
switch (item.type) {
|
||||
switch (formItem.type) {
|
||||
case 'boolean':
|
||||
return ToggleSwitch
|
||||
case 'number':
|
||||
|
||||
@@ -5,239 +5,242 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('FormRadioGroup', () => {
|
||||
beforeAll(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
|
||||
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { RadioButton }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
describe(
|
||||
(FormRadioGroup as { __name?: string }).__name ?? 'FormRadioGroup',
|
||||
() => {
|
||||
beforeAll(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { RadioButton }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('normalizedOptions computed property', () => {
|
||||
it('handles string array options', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'option1',
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('option1')
|
||||
expect(radioButtons[1].props('value')).toBe('option2')
|
||||
expect(radioButtons[2].props('value')).toBe('option3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('option1')
|
||||
expect(labels[1].text()).toBe('option2')
|
||||
expect(labels[2].text()).toBe('option3')
|
||||
})
|
||||
|
||||
it('handles SettingOption array', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Small', value: 'sm' },
|
||||
{ text: 'Medium', value: 'md' },
|
||||
{ text: 'Large', value: 'lg' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'md',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('sm')
|
||||
expect(radioButtons[1].props('value')).toBe('md')
|
||||
expect(radioButtons[2].props('value')).toBe('lg')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Small')
|
||||
expect(labels[1].text()).toBe('Medium')
|
||||
expect(labels[2].text()).toBe('Large')
|
||||
})
|
||||
|
||||
it('handles SettingOption with undefined value (uses text as value)', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option A', value: undefined },
|
||||
{ text: 'Option B' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Option A',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Option A')
|
||||
expect(radioButtons[1].props('value')).toBe('Option B')
|
||||
})
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: '1' },
|
||||
{ name: 'Second Option', id: '2' },
|
||||
{ name: 'Third Option', id: '3' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 2,
|
||||
options,
|
||||
optionLabel: 'name',
|
||||
optionValue: 'id',
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('1')
|
||||
expect(radioButtons[1].props('value')).toBe('2')
|
||||
expect(radioButtons[2].props('value')).toBe('3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
expect(labels[1].text()).toBe('Second Option')
|
||||
expect(labels[2].text()).toBe('Third Option')
|
||||
})
|
||||
|
||||
it('handles mixed array with strings and SettingOptions', () => {
|
||||
const options: (string | SettingOption)[] = [
|
||||
'Simple String',
|
||||
{ text: 'Complex Option', value: 'complex' },
|
||||
'Another String'
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'complex',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Simple String')
|
||||
expect(radioButtons[1].props('value')).toBe('complex')
|
||||
expect(radioButtons[2].props('value')).toBe('Another String')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Simple String')
|
||||
expect(labels[1].text()).toBe('Complex Option')
|
||||
expect(labels[2].text()).toBe('Another String')
|
||||
})
|
||||
|
||||
it('handles empty options array', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: [],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles undefined options gracefully', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: undefined,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(1)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('component functionality', () => {
|
||||
it('sets correct input-id and name attributes', () => {
|
||||
const options = ['A', 'B']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'A',
|
||||
options,
|
||||
id: 'my-radio-group'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
|
||||
expect(radioButtons[0].props('name')).toBe('my-radio-group')
|
||||
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
|
||||
expect(radioButtons[1].props('name')).toBe('my-radio-group')
|
||||
})
|
||||
|
||||
it('associates labels with radio buttons correctly', () => {
|
||||
const options = ['Yes', 'No']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Yes',
|
||||
options,
|
||||
id: 'confirm-radio'
|
||||
})
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
|
||||
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
|
||||
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
|
||||
})
|
||||
|
||||
it('sets aria-describedby attribute correctly', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option 1', value: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].attributes('aria-describedby')).toBe(
|
||||
'Option 1-label'
|
||||
)
|
||||
expect(radioButtons[1].attributes('aria-describedby')).toBe(
|
||||
'Option 2-label'
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('normalizedOptions computed property', () => {
|
||||
it('handles string array options', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'option1',
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('option1')
|
||||
expect(radioButtons[1].props('value')).toBe('option2')
|
||||
expect(radioButtons[2].props('value')).toBe('option3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('option1')
|
||||
expect(labels[1].text()).toBe('option2')
|
||||
expect(labels[2].text()).toBe('option3')
|
||||
})
|
||||
|
||||
it('handles SettingOption array', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Small', value: 'sm' },
|
||||
{ text: 'Medium', value: 'md' },
|
||||
{ text: 'Large', value: 'lg' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'md',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('sm')
|
||||
expect(radioButtons[1].props('value')).toBe('md')
|
||||
expect(radioButtons[2].props('value')).toBe('lg')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Small')
|
||||
expect(labels[1].text()).toBe('Medium')
|
||||
expect(labels[2].text()).toBe('Large')
|
||||
})
|
||||
|
||||
it('handles SettingOption with undefined value (uses text as value)', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option A', value: undefined },
|
||||
{ text: 'Option B' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Option A',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Option A')
|
||||
expect(radioButtons[1].props('value')).toBe('Option B')
|
||||
})
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: '1' },
|
||||
{ name: 'Second Option', id: '2' },
|
||||
{ name: 'Third Option', id: '3' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 2,
|
||||
options,
|
||||
optionLabel: 'name',
|
||||
optionValue: 'id',
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('1')
|
||||
expect(radioButtons[1].props('value')).toBe('2')
|
||||
expect(radioButtons[2].props('value')).toBe('3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
expect(labels[1].text()).toBe('Second Option')
|
||||
expect(labels[2].text()).toBe('Third Option')
|
||||
})
|
||||
|
||||
it('handles mixed array with strings and SettingOptions', () => {
|
||||
const options: (string | SettingOption)[] = [
|
||||
'Simple String',
|
||||
{ text: 'Complex Option', value: 'complex' },
|
||||
'Another String'
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'complex',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Simple String')
|
||||
expect(radioButtons[1].props('value')).toBe('complex')
|
||||
expect(radioButtons[2].props('value')).toBe('Another String')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Simple String')
|
||||
expect(labels[1].text()).toBe('Complex Option')
|
||||
expect(labels[2].text()).toBe('Another String')
|
||||
})
|
||||
|
||||
it('handles empty options array', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: [],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles undefined options gracefully', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: undefined,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(1)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('component functionality', () => {
|
||||
it('sets correct input-id and name attributes', () => {
|
||||
const options = ['A', 'B']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'A',
|
||||
options,
|
||||
id: 'my-radio-group'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
|
||||
expect(radioButtons[0].props('name')).toBe('my-radio-group')
|
||||
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
|
||||
expect(radioButtons[1].props('name')).toBe('my-radio-group')
|
||||
})
|
||||
|
||||
it('associates labels with radio buttons correctly', () => {
|
||||
const options = ['Yes', 'No']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Yes',
|
||||
options,
|
||||
id: 'confirm-radio'
|
||||
})
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
|
||||
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
|
||||
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
|
||||
})
|
||||
|
||||
it('sets aria-describedby attribute correctly', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option 1', value: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].attributes('aria-describedby')).toBe(
|
||||
'Option 1-label'
|
||||
)
|
||||
expect(radioButtons[1].attributes('aria-describedby')).toBe(
|
||||
'Option 2-label'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ import { computed } from 'vue'
|
||||
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
const props = defineProps<{
|
||||
const { modelValue, options, optionLabel, optionValue, id } = defineProps<{
|
||||
modelValue: T
|
||||
options?: (string | SettingOption | Record<string, string>)[]
|
||||
optionLabel?: string
|
||||
@@ -39,9 +39,9 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed<SettingOption[]>(() => {
|
||||
if (!props.options) return []
|
||||
if (!options) return []
|
||||
|
||||
return props.options.map((option) => {
|
||||
return options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { text: option, value: option }
|
||||
}
|
||||
@@ -54,8 +54,8 @@ const normalizedOptions = computed<SettingOption[]>(() => {
|
||||
}
|
||||
// Handle optionLabel/optionValue
|
||||
return {
|
||||
text: option[props.optionLabel || 'text'] || 'Unknown',
|
||||
value: option[props.optionValue || 'value']
|
||||
text: option[optionLabel || 'text'] || 'Unknown',
|
||||
value: option[optionValue || 'value']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,24 +30,25 @@ import InputNumber from 'primevue/inputnumber'
|
||||
import Knob from 'primevue/knob'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number
|
||||
inputClass?: string
|
||||
knobClass?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
resolution?: number
|
||||
}>()
|
||||
const { modelValue, inputClass, knobClass, min, max, step, resolution } =
|
||||
defineProps<{
|
||||
modelValue: number
|
||||
inputClass?: string
|
||||
knobClass?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
resolution?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void
|
||||
}>()
|
||||
|
||||
const localValue = ref(props.modelValue)
|
||||
const localValue = ref(modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
localValue.value = newValue
|
||||
}
|
||||
@@ -56,18 +57,18 @@ watch(
|
||||
const updateValue = (newValue: number | null) => {
|
||||
if (newValue === null) {
|
||||
// If the input is cleared, reset to the minimum value or 0
|
||||
newValue = Number(props.min) || 0
|
||||
newValue = Number(min) || 0
|
||||
}
|
||||
|
||||
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
|
||||
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
|
||||
const step = Number(props.step) || 1
|
||||
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
|
||||
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
|
||||
const stepVal = Number(step) || 1
|
||||
|
||||
// Ensure the value is within the allowed range
|
||||
newValue = Math.max(min, Math.min(max, newValue))
|
||||
newValue = Math.max(minVal, Math.min(maxVal, newValue))
|
||||
|
||||
// Round to the nearest step
|
||||
newValue = Math.round(newValue / step) * step
|
||||
newValue = Math.round(newValue / stepVal) * stepVal
|
||||
|
||||
// Update local value and emit change
|
||||
localValue.value = newValue
|
||||
@@ -76,11 +77,11 @@ const updateValue = (newValue: number | null) => {
|
||||
|
||||
const displayValue = (value: number): string => {
|
||||
updateValue(value)
|
||||
const stepString = (props.step ?? 1).toString()
|
||||
const resolution = stepString.includes('.')
|
||||
const stepString = (step ?? 1).toString()
|
||||
const decimalPlaces = stepString.includes('.')
|
||||
? stepString.split('.')[1].length
|
||||
: 0
|
||||
return value.toFixed(props.resolution ?? resolution)
|
||||
return value.toFixed(resolution ?? decimalPlaces)
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -29,7 +29,7 @@ import InputNumber from 'primevue/inputnumber'
|
||||
import Slider from 'primevue/slider'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const { modelValue, inputClass, sliderClass, min, max, step } = defineProps<{
|
||||
modelValue: number
|
||||
inputClass?: string
|
||||
sliderClass?: string
|
||||
@@ -42,10 +42,10 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void
|
||||
}>()
|
||||
|
||||
const localValue = ref(props.modelValue)
|
||||
const localValue = ref(modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
localValue.value = newValue
|
||||
}
|
||||
@@ -54,18 +54,18 @@ watch(
|
||||
const updateValue = (newValue: number | null) => {
|
||||
if (newValue === null) {
|
||||
// If the input is cleared, reset to the minimum value or 0
|
||||
newValue = Number(props.min) || 0
|
||||
newValue = Number(min) || 0
|
||||
}
|
||||
|
||||
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
|
||||
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
|
||||
const step = Number(props.step) || 1
|
||||
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
|
||||
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
|
||||
const stepVal = Number(step) || 1
|
||||
|
||||
// Ensure the value is within the allowed range
|
||||
newValue = Math.max(min, Math.min(max, newValue))
|
||||
newValue = Math.max(minVal, Math.min(maxVal, newValue))
|
||||
|
||||
// Round to the nearest step
|
||||
newValue = Math.round(newValue / step) * step
|
||||
newValue = Math.round(newValue / stepVal) * stepVal
|
||||
|
||||
// Update local value and emit change
|
||||
localValue.value = newValue
|
||||
|
||||
@@ -41,7 +41,6 @@ const spinnerSizeClass = computed(() => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'h-6 w-6 border-2'
|
||||
case 'md':
|
||||
default:
|
||||
return 'h-12 w-12 border-4'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="no-results-placeholder h-full p-8" :class="props.class">
|
||||
<div :class="cn('no-results-placeholder h-full p-8', className)">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center">
|
||||
@@ -25,8 +25,16 @@
|
||||
import Card from 'primevue/card'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
class: className,
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
textClass,
|
||||
buttonLabel
|
||||
} = defineProps<{
|
||||
class?: string
|
||||
icon?: string
|
||||
title: string
|
||||
|
||||
@@ -19,7 +19,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
describe('SearchBox', () => {
|
||||
describe((SearchBox as { __name?: string }).__name ?? 'SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SearchFilter {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
defineProps<Omit<SearchFilter, 'id'>>()
|
||||
const { text, badge, badgeClass } = defineProps<Omit<SearchFilter, 'id'>>()
|
||||
defineEmits(['remove'])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabView v-if="stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
v-for="device in stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
@@ -31,7 +31,7 @@
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
<DeviceInfo v-else :device="props.stats.devices[0]" />
|
||||
<DeviceInfo v-else :device="stats.devices[0]" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -48,16 +48,16 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const { stats } = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
|
||||
const systemInfo = computed(() => ({
|
||||
...props.stats.system,
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
...stats.system,
|
||||
argv: stats.system.argv.join(' ')
|
||||
}))
|
||||
|
||||
const hasDevices = computed(() => props.stats.devices.length > 0)
|
||||
const hasDevices = computed(() => stats.devices.length > 0)
|
||||
|
||||
type SystemInfoKey = keyof SystemStats['system']
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
|
||||
:class="props.class"
|
||||
:class="className"
|
||||
:value="renderedRoot.children"
|
||||
selection-mode="single"
|
||||
:pt="{
|
||||
@@ -37,10 +37,6 @@
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import Tree from 'primevue/tree'
|
||||
@@ -60,6 +56,10 @@ import type {
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
||||
required: true
|
||||
})
|
||||
@@ -68,7 +68,7 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
|
||||
// Tracks whether the caller has set the selectionKeys model.
|
||||
const storeSelectionKeys = selectionKeys.value !== undefined
|
||||
|
||||
const props = defineProps<{
|
||||
const { root, class: className } = defineProps<{
|
||||
root: TreeExplorerNode
|
||||
class?: string
|
||||
}>()
|
||||
@@ -90,7 +90,7 @@ const {
|
||||
)
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const renderedRoot = fillNodeInfo(props.root)
|
||||
const renderedRoot = fillNodeInfo(root)
|
||||
return newFolderNode.value
|
||||
? combineTrees(renderedRoot, newFolderNode.value)
|
||||
: renderedRoot
|
||||
|
||||
@@ -19,7 +19,7 @@ const i18n = createI18n({
|
||||
messages: {}
|
||||
})
|
||||
|
||||
describe('TreeExplorerTreeNode', () => {
|
||||
describe(TreeExplorerTreeNode.__name ?? 'TreeExplorerTreeNode', () => {
|
||||
const mockNode = {
|
||||
key: '1',
|
||||
label: 'Test Node',
|
||||
|
||||
@@ -5,21 +5,21 @@
|
||||
'tree-node',
|
||||
{
|
||||
'can-drop': canDrop,
|
||||
'tree-folder': !props.node.leaf,
|
||||
'tree-leaf': props.node.leaf
|
||||
'tree-folder': !node.leaf,
|
||||
'tree-leaf': node.leaf
|
||||
}
|
||||
]"
|
||||
:data-testid="`tree-node-${node.key}`"
|
||||
>
|
||||
<div class="node-content">
|
||||
<span class="node-label">
|
||||
<slot name="before-label" :node="props.node" />
|
||||
<slot name="before-label" :node="node" />
|
||||
<EditableText
|
||||
:model-value="node.label"
|
||||
:is-editing="isEditing"
|
||||
@edit="handleRename"
|
||||
/>
|
||||
<slot name="after-label" :node="props.node" />
|
||||
<slot name="after-label" :node="node" />
|
||||
</span>
|
||||
<Badge
|
||||
v-if="showNodeBadgeText"
|
||||
@@ -31,7 +31,7 @@
|
||||
<div
|
||||
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
<slot name="actions" :node="node" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,7 +52,7 @@ import type {
|
||||
TreeExplorerDragAndDropData
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
const { node } = defineProps<{
|
||||
node: RenderedTreeExplorerNode
|
||||
}>()
|
||||
|
||||
@@ -67,20 +67,20 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const nodeBadgeText = computed<string>(() => {
|
||||
if (props.node.leaf) {
|
||||
if (node.leaf) {
|
||||
return ''
|
||||
}
|
||||
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
|
||||
return props.node.badgeText
|
||||
if (node.badgeText !== undefined && node.badgeText !== null) {
|
||||
return node.badgeText
|
||||
}
|
||||
return props.node.totalLeaves.toString()
|
||||
return node.totalLeaves.toString()
|
||||
})
|
||||
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
|
||||
|
||||
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
|
||||
const isEditing = computed<boolean>(() => node.isEditingLabel ?? false)
|
||||
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
|
||||
const handleRename = (newName: string) => {
|
||||
handleEditLabel?.(props.node, newName)
|
||||
handleEditLabel?.(node, newName)
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
@@ -89,21 +89,21 @@ const canDrop = ref(false)
|
||||
const treeNodeElementGetter = () =>
|
||||
container.value?.closest('.p-tree-node-content') as HTMLElement
|
||||
|
||||
if (props.node.draggable) {
|
||||
if (node.draggable) {
|
||||
usePragmaticDraggable(treeNodeElementGetter, {
|
||||
getInitialData: () => {
|
||||
return {
|
||||
type: 'tree-explorer-node',
|
||||
data: props.node
|
||||
data: node
|
||||
}
|
||||
},
|
||||
onDragStart: () => emit('dragStart', props.node),
|
||||
onDrop: () => emit('dragEnd', props.node),
|
||||
onGenerateDragPreview: props.node.renderDragPreview
|
||||
onDragStart: () => emit('dragStart', node),
|
||||
onDrop: () => emit('dragEnd', node),
|
||||
onGenerateDragPreview: node.renderDragPreview
|
||||
? ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
render: ({ container }) => {
|
||||
return props.node.renderDragPreview?.(container)
|
||||
return node.renderDragPreview?.(container)
|
||||
},
|
||||
nativeSetDragImage
|
||||
})
|
||||
@@ -112,14 +112,14 @@ if (props.node.draggable) {
|
||||
})
|
||||
}
|
||||
|
||||
if (props.node.droppable) {
|
||||
if (node.droppable) {
|
||||
usePragmaticDroppable(treeNodeElementGetter, {
|
||||
onDrop: async (event) => {
|
||||
const dndData = event.source.data as TreeExplorerDragAndDropData
|
||||
if (dndData.type === 'tree-explorer-node') {
|
||||
await props.node.handleDrop?.(dndData)
|
||||
await node.handleDrop?.(dndData)
|
||||
canDrop.value = false
|
||||
emit('itemDropped', props.node, dndData.data)
|
||||
emit('itemDropped', node, dndData.data)
|
||||
}
|
||||
},
|
||||
onDragEnter: (event) => {
|
||||
|
||||
@@ -6,10 +6,11 @@ import InputText from 'primevue/inputtext'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import UrlInput from './UrlInput.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('UrlInput', () => {
|
||||
import UrlInput from './UrlInput.vue'
|
||||
|
||||
describe(UrlInput.__name ?? 'UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
'pi pi-times cursor-pointer text-red-500':
|
||||
validationState === ValidationState.INVALID
|
||||
}"
|
||||
@click="validateUrl(props.modelValue)"
|
||||
@click="validateUrl(model)"
|
||||
/>
|
||||
</IconField>
|
||||
</template>
|
||||
@@ -32,40 +32,34 @@ import { isValidUrl } from '@/utils/formatUtil'
|
||||
import { checkUrlReachable } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const { validateUrlFn } = defineProps<{
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'state-change': [state: ValidationState]
|
||||
}>()
|
||||
|
||||
const validationState = ref<ValidationState>(ValidationState.IDLE)
|
||||
|
||||
const cleanInput = (value: string): string =>
|
||||
value ? value.replace(/\s+/g, '') : ''
|
||||
value ? value.replaceAll(/\s+/g, '') : ''
|
||||
|
||||
// Add internal value state
|
||||
const internalValue = ref(cleanInput(props.modelValue))
|
||||
const internalValue = ref(cleanInput(model.value))
|
||||
|
||||
// Watch for external modelValue changes
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newValue: string) => {
|
||||
internalValue.value = cleanInput(newValue)
|
||||
await validateUrl(newValue)
|
||||
}
|
||||
)
|
||||
watch(model, async (newValue: string) => {
|
||||
internalValue.value = cleanInput(newValue)
|
||||
await validateUrl(newValue)
|
||||
})
|
||||
|
||||
watch(validationState, (newState) => {
|
||||
emit('state-change', newState)
|
||||
})
|
||||
|
||||
// Validate on mount
|
||||
onMounted(async () => {
|
||||
await validateUrl(props.modelValue)
|
||||
await validateUrl(model.value)
|
||||
})
|
||||
|
||||
const handleInput = (value: string | undefined) => {
|
||||
@@ -87,7 +81,7 @@ const handleBlur = async () => {
|
||||
}
|
||||
|
||||
// Emit the update only on blur
|
||||
emit('update:modelValue', normalizedUrl)
|
||||
model.value = normalizedUrl
|
||||
}
|
||||
|
||||
// Default validation implementation
|
||||
@@ -113,7 +107,7 @@ const validateUrl = async (value: string) => {
|
||||
|
||||
validationState.value = ValidationState.LOADING
|
||||
try {
|
||||
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
|
||||
const isValid = await (validateUrlFn ?? defaultValidateUrl)(url)
|
||||
validationState.value = isValid
|
||||
? ValidationState.VALID
|
||||
: ValidationState.INVALID
|
||||
|
||||
@@ -23,7 +23,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
describe(UserAvatar.__name ?? 'UserAvatar', () => {
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('UserCredit', () => {
|
||||
describe(UserCredit.__name ?? 'UserCredit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBalance.value = {
|
||||
|
||||
@@ -444,7 +444,6 @@ const distributions = computed(() => {
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
case 'localhost':
|
||||
return [TemplateIncludeOnDistributionEnum.Local]
|
||||
case 'desktop':
|
||||
default:
|
||||
if (systemStatsStore.systemStats?.system.os === 'darwin') {
|
||||
return [
|
||||
@@ -595,12 +594,10 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
} else if (source === 'sort' && isPopularNav && !isPopularSort) {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
// reset the category to 'All Templates' to avoid a confusing state.
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
selectedNavItem.value = 'all'
|
||||
}
|
||||
selectedNavItem.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,37 +678,37 @@ const runsOnOptions = computed(() =>
|
||||
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
|
||||
})
|
||||
}
|
||||
if (selectedModelObjects.value.length === 1) {
|
||||
return selectedModelObjects.value[0].name
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
if (selectedUseCaseObjects.value.length === 1) {
|
||||
return selectedUseCaseObjects.value[0].name
|
||||
}
|
||||
return t('templateWorkflows.useCasesSelected', {
|
||||
count: selectedUseCaseObjects.value.length
|
||||
})
|
||||
})
|
||||
|
||||
const runsOnFilterLabel = computed(() => {
|
||||
if (selectedRunsOnObjects.value.length === 0) {
|
||||
return t('templateWorkflows.runsOnFilter', 'Runs On')
|
||||
} else if (selectedRunsOnObjects.value.length === 1) {
|
||||
return selectedRunsOnObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.runsOnSelected', {
|
||||
count: selectedRunsOnObjects.value.length
|
||||
})
|
||||
}
|
||||
if (selectedRunsOnObjects.value.length === 1) {
|
||||
return selectedRunsOnObjects.value[0].name
|
||||
}
|
||||
return t('templateWorkflows.runsOnSelected', {
|
||||
count: selectedRunsOnObjects.value.length
|
||||
})
|
||||
})
|
||||
|
||||
// Sort options
|
||||
|
||||
@@ -26,7 +26,7 @@ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return undefined
|
||||
return
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
const { title } = defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -18,17 +18,35 @@
|
||||
<div class="flex justify-end gap-4">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex justify-start gap-4"
|
||||
class="flex flex-col justify-start gap-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="doNotAskAgain"
|
||||
class="flex justify-start gap-4"
|
||||
input-id="doNotAskAgain"
|
||||
binary
|
||||
/>
|
||||
<label for="doNotAskAgain" severity="secondary">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-8"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openBlueprintOverwriteSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -92,17 +110,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
message,
|
||||
type,
|
||||
onConfirm: onConfirmProp,
|
||||
itemList,
|
||||
hint
|
||||
} = defineProps<{
|
||||
message: string
|
||||
type: ConfirmationDialogType
|
||||
onConfirm: (value?: boolean) => void
|
||||
@@ -114,17 +138,25 @@ const { t } = useI18n()
|
||||
|
||||
const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
function openBlueprintOverwriteSetting() {
|
||||
useDialogStore().closeDialog()
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite'
|
||||
)
|
||||
}
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
const onDeny = () => {
|
||||
props.onConfirm(false)
|
||||
onConfirmProp(false)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
|
||||
if (type === 'overwriteBlueprint' && doNotAskAgain.value)
|
||||
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
|
||||
props.onConfirm(true)
|
||||
onConfirmProp(true)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,7 +30,7 @@ const createMockNode = (type: string, version?: string): LGraphNode =>
|
||||
outputs: []
|
||||
})
|
||||
|
||||
describe('MissingCoreNodesMessage', () => {
|
||||
describe(MissingCoreNodesMessage.__name ?? 'MissingCoreNodesMessage', () => {
|
||||
const mockSystemStatsStore = {
|
||||
systemStats: null as { system?: { comfyui_version?: string } } | null,
|
||||
refetchSystemStats: vi.fn()
|
||||
|
||||
@@ -5,11 +5,34 @@
|
||||
:title="t('missingModelsDialog.missingModels')"
|
||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||
/>
|
||||
<div class="mb-4 flex gap-1">
|
||||
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||
<template #option="{ option }">
|
||||
@@ -31,16 +54,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
@@ -69,7 +94,7 @@ interface ModelInfo {
|
||||
folder_path?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
const { missingModels: missingModelsProp, paths } = defineProps<{
|
||||
missingModels: ModelInfo[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
@@ -78,11 +103,19 @@ const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
const paths = props.paths[model.directory]
|
||||
if (model.directory_invalid || !paths) {
|
||||
return missingModelsProp.map((model) => {
|
||||
const modelPaths = paths[model.directory]
|
||||
if (model.directory_invalid || !modelPaths) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
@@ -97,7 +130,7 @@ const missingModels = computed(() => {
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
folder_path: paths[0]
|
||||
folder_path: modelPaths[0]
|
||||
}
|
||||
modelDownloads.value[model.name] = downloadInfo
|
||||
if (!whiteListedUrls.has(model.url)) {
|
||||
@@ -124,7 +157,7 @@ const missingModels = computed(() => {
|
||||
progress: downloadInfo.progress,
|
||||
error: downloadInfo.error,
|
||||
name: model.name,
|
||||
paths: paths,
|
||||
paths: modelPaths,
|
||||
folderPath: downloadInfo.folder_path
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const props = defineProps<{
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
@@ -63,7 +63,7 @@ const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
return missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
|
||||
@@ -1,55 +1,86 @@
|
||||
<template>
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 py-2 px-4">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainNodes"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainNodes">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingNodesSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -60,10 +91,24 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
|
||||
})
|
||||
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingNodesWarning'
|
||||
)
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
|
||||
@@ -25,17 +25,22 @@ import { ref } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
message,
|
||||
defaultValue,
|
||||
onConfirm: onConfirmProp,
|
||||
placeholder
|
||||
} = defineProps<{
|
||||
message: string
|
||||
defaultValue: string
|
||||
onConfirm: (value: string) => void
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const inputValue = ref<string>(props.defaultValue)
|
||||
const inputValue = ref<string>(defaultValue)
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(inputValue.value)
|
||||
onConfirmProp(inputValue.value)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const mountOption = (
|
||||
}
|
||||
})
|
||||
|
||||
describe('CreditTopUpOption', () => {
|
||||
describe(CreditTopUpOption.__name ?? 'CreditTopUpOption', () => {
|
||||
it('renders credit amount and description', () => {
|
||||
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
|
||||
@@ -11,23 +11,20 @@ import { computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const props = defineProps<{
|
||||
const { errorMessage, repoOwner, repoName } = defineProps<{
|
||||
errorMessage: string
|
||||
repoOwner: string
|
||||
repoName: string
|
||||
}>()
|
||||
|
||||
const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
const queryString = computed(() => `${errorMessage} is:issue`)
|
||||
|
||||
/**
|
||||
* Open GitHub issues search and track telemetry.
|
||||
*/
|
||||
const openGitHubIssues = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(queryString.value)
|
||||
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
|
||||
const url = `https://github.com/${repoOwner}/${repoName}/issues?q=${query}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -268,7 +268,7 @@ async function saveKeybinding() {
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
if (!combo || commandId == null) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
||||
<TabPanel :value class="h-full w-full" :class="panelClass">
|
||||
<div class="flex h-full w-full flex-col gap-2">
|
||||
<slot name="header" />
|
||||
<ScrollPanel class="h-0 grow pr-2">
|
||||
@@ -14,7 +14,7 @@
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
const { value, class: panelClass } = defineProps<{
|
||||
value: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
normalizeI18nKey: vi.fn()
|
||||
}))
|
||||
|
||||
describe('SettingItem', () => {
|
||||
describe(SettingItem.__name ?? 'SettingItem', () => {
|
||||
const mountComponent = (props: Record<string, unknown>, options = {}) => {
|
||||
return mount(SettingItem, {
|
||||
global: {
|
||||
|
||||
@@ -76,7 +76,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
describe('UsageLogsTable', () => {
|
||||
describe(UsageLogsTable.__name ?? 'UsageLogsTable', () => {
|
||||
const mockEventsResponse = {
|
||||
events: [
|
||||
{
|
||||
|
||||
@@ -169,9 +169,10 @@ const loadEvents = async () => {
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Error loading events:', err)
|
||||
} catch (errorCaught) {
|
||||
error.value =
|
||||
errorCaught instanceof Error ? errorCaught.message : 'Unknown error'
|
||||
console.error('Error loading events:', errorCaught)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
describe('ApiKeyForm', () => {
|
||||
describe(ApiKeyForm.__name ?? 'ApiKeyForm', () => {
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
|
||||
@@ -58,7 +58,7 @@ vi.mock('primevue/usetoast', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('SignInForm', () => {
|
||||
describe(SignInForm.__name ?? 'SignInForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSendPasswordReset.mockReset()
|
||||
@@ -110,12 +110,17 @@ describe('SignInForm', () => {
|
||||
'span.text-muted.text-base.font-medium.cursor-pointer'
|
||||
)
|
||||
|
||||
// Mock getElementById to track focus
|
||||
// Mock querySelector to track focus on the email input
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
const originalQuerySelector = document.querySelector.bind(document)
|
||||
const spy = vi
|
||||
.spyOn(document, 'querySelector')
|
||||
.mockImplementation((selector: string) => {
|
||||
if (selector === '#comfy-org-sign-in-email')
|
||||
return mockElement as HTMLElement
|
||||
return originalQuerySelector(selector)
|
||||
})
|
||||
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
@@ -129,10 +134,9 @@ describe('SignInForm', () => {
|
||||
})
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(spy).toHaveBeenCalledWith('#comfy-org-sign-in-email')
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
|
||||
// Should NOT call sendPasswordReset
|
||||
expect(mockSendPasswordReset).not.toHaveBeenCalled()
|
||||
@@ -212,7 +216,7 @@ describe('SignInForm', () => {
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(false)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Fallback test - check HTML content if component rendering fails
|
||||
mockLoading = true
|
||||
const wrapper = mountComponent()
|
||||
@@ -270,21 +274,25 @@ describe('SignInForm', () => {
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById to track focus
|
||||
// Mock querySelector to track focus on the email input
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
const originalQuerySelector = document.querySelector.bind(document)
|
||||
const spy = vi
|
||||
.spyOn(document, 'querySelector')
|
||||
.mockImplementation((selector: string) => {
|
||||
if (selector === '#comfy-org-sign-in-email')
|
||||
return mockElement as HTMLElement
|
||||
return originalQuerySelector(selector)
|
||||
})
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
|
||||
// Should focus email input
|
||||
expect(document.getElementById).toHaveBeenCalledWith(
|
||||
'comfy-org-sign-in-email'
|
||||
)
|
||||
expect(spy).toHaveBeenCalledWith('#comfy-org-sign-in-email')
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
|
||||
@@ -120,7 +120,7 @@ const handleForgotPassword = async (
|
||||
life: 5_000
|
||||
})
|
||||
// Focus the email input
|
||||
document.getElementById(emailInputId)?.focus?.()
|
||||
document.querySelector<HTMLElement>(`#${emailInputId}`)?.focus?.()
|
||||
return
|
||||
}
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
|
||||
@@ -52,7 +52,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
const { cancelAt } = defineProps<{
|
||||
cancelAt?: string
|
||||
}>()
|
||||
|
||||
@@ -64,7 +64,7 @@ const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const dateStr = props.cancelAt ?? subscription.value?.endDate
|
||||
const dateStr = cancelAt ?? subscription.value?.endDate
|
||||
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
|
||||
@@ -66,7 +66,7 @@ interface Props {
|
||||
buttonStyles?: Record<string, string>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const { buttonStyles } = defineProps<Props>()
|
||||
const buttonRef = ref<ComponentPublicInstance | null>(null)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
@@ -65,11 +65,12 @@ const updateWidgets = () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
whenever(
|
||||
() => canvasStore.canvas,
|
||||
(canvas) =>
|
||||
(canvas.onDrawForeground = useChainCallback(
|
||||
(canvas) => {
|
||||
canvas.onDrawForeground = useChainCallback(
|
||||
canvas.onDrawForeground,
|
||||
updateWidgets
|
||||
)),
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<NodeContextMenu />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
@@ -121,6 +122,7 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -160,6 +162,8 @@ import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -169,11 +173,9 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
@@ -246,9 +248,9 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
const allNodes = computed((): VueNodeData[] => [
|
||||
...(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
])
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
@@ -114,8 +114,8 @@ const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
|
||||
useZoomControls()
|
||||
|
||||
const stringifiedMinimapStyles = computed(() => {
|
||||
const buttonGroupKeys = ['borderRadius']
|
||||
const buttonKeys = ['borderRadius']
|
||||
const buttonGroupKeys = new Set(['borderRadius'])
|
||||
const buttonKeys = new Set(['borderRadius'])
|
||||
const additionalButtonStyles = {
|
||||
border: 'none'
|
||||
}
|
||||
@@ -124,14 +124,12 @@ const stringifiedMinimapStyles = computed(() => {
|
||||
|
||||
const buttonStyles = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(containerStyles).filter(([key]) =>
|
||||
buttonKeys.includes(key)
|
||||
)
|
||||
Object.entries(containerStyles).filter(([key]) => buttonKeys.has(key))
|
||||
),
|
||||
...additionalButtonStyles
|
||||
}
|
||||
const buttonGroupStyles = Object.entries(containerStyles)
|
||||
.filter(([key]) => buttonGroupKeys.includes(key))
|
||||
.filter(([key]) => buttonGroupKeys.has(key))
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
|
||||
|
||||
return { buttonStyles, buttonGroupStyles }
|
||||
|
||||
@@ -248,8 +248,8 @@ defineExpose({ toggle, hide, isOpen, show })
|
||||
function showColorPopover(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
const target = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
const target = [...(event.currentTarget as HTMLElement).children].find((el) =>
|
||||
el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
colorPickerMenu.value?.toggle(event, target)
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ const left = ref<string>()
|
||||
const top = ref<string>()
|
||||
|
||||
function hideTooltip() {
|
||||
return (tooltipText.value = '')
|
||||
tooltipText.value = ''
|
||||
}
|
||||
|
||||
async function showTooltip(tooltip: string | null | undefined) {
|
||||
if (!tooltip) return
|
||||
|
||||
left.value = comfyApp.canvas.mouse[0] + 'px'
|
||||
top.value = comfyApp.canvas.mouse[1] + 'px'
|
||||
left.value = `${comfyApp.canvas.mouse[0]}px`
|
||||
top.value = `${comfyApp.canvas.mouse[1]}px`
|
||||
tooltipText.value = tooltip
|
||||
|
||||
await nextTick()
|
||||
@@ -50,11 +50,11 @@ async function showTooltip(tooltip: string | null | undefined) {
|
||||
if (!rect) return
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
left.value = comfyApp.canvas.mouse[0] - rect.width + 'px'
|
||||
left.value = `${comfyApp.canvas.mouse[0] - rect.width}px`
|
||||
}
|
||||
|
||||
if (rect.top < 0) {
|
||||
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
|
||||
top.value = `${comfyApp.canvas.mouse[1] + rect.height}px`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ vi.mock('@/stores/nodeDefStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
describe('SelectionToolbox', () => {
|
||||
describe(SelectionToolbox.__name ?? 'SelectionToolbox', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
<NodeContextMenu />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
import NodeContextMenu from './NodeContextMenu.vue'
|
||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||
@@ -84,16 +82,14 @@ const { visible } = useSelectionToolboxPosition(toolboxRef)
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
canvasStore.selectedItems
|
||||
.map(
|
||||
(item) =>
|
||||
extensionService
|
||||
.invokeExtensions('getSelectionToolboxCommands', item)
|
||||
.flat() as string[]
|
||||
)
|
||||
.flat()
|
||||
canvasStore.selectedItems.flatMap(
|
||||
(item) =>
|
||||
extensionService
|
||||
.invokeExtensions('getSelectionToolboxCommands', item)
|
||||
.flat() as string[]
|
||||
)
|
||||
)
|
||||
return Array.from(commandIds)
|
||||
return [...commandIds]
|
||||
.map((commandId) => commandStore.getCommand(commandId))
|
||||
.filter((command): command is ComfyCommandImpl => command !== undefined)
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ const createWrapper = (props = {}) => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('ZoomControlsModal', () => {
|
||||
describe(ZoomControlsModal.__name ?? 'ZoomControlsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ interface Props {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { visible } = defineProps<Props>()
|
||||
|
||||
const interval = ref<number | null>(null)
|
||||
|
||||
@@ -132,7 +132,7 @@ const zoomToFitCommandText = computed(() =>
|
||||
const zoomInputContainer = ref<HTMLDivElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
() => visible,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
describe('BypassButton', () => {
|
||||
describe(BypassButton.__name ?? 'BypassButton', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
let commandStore: ReturnType<typeof useCommandStore>
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
|
||||
|
||||
// Mock the colorUtil module
|
||||
vi.mock('@/utils/colorUtil', () => ({
|
||||
adjustColor: vi.fn((color: string) => color + '_light')
|
||||
adjustColor: vi.fn((color: string) => `${color}_light`)
|
||||
}))
|
||||
|
||||
// Mock the litegraphUtil module
|
||||
@@ -56,7 +56,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isReroute: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('ColorPickerButton', () => {
|
||||
describe(ColorPickerButton.__name ?? 'ColorPickerButton', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ interface Emits {
|
||||
(e: 'submenu-click', subOption: SubMenuOption): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { option } = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { getCurrentShape } = useNodeCustomization()
|
||||
@@ -113,9 +113,9 @@ const isShapeSelected = (subOption: SubMenuOption): boolean => {
|
||||
|
||||
const isColorSubmenu = computed(() => {
|
||||
return (
|
||||
props.option.submenu &&
|
||||
props.option.submenu.length > 0 &&
|
||||
props.option.submenu.every((item) => item.color && !item.icon)
|
||||
option.submenu &&
|
||||
option.submenu.length > 0 &&
|
||||
option.submenu.every((item) => item.color && !item.icon)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('ExecuteButton', () => {
|
||||
describe(ExecuteButton.__name ?? 'ExecuteButton', () => {
|
||||
let mockCanvas: LGraphCanvas
|
||||
let mockSelectedNodes: LGraphNode[]
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
defineProps<{
|
||||
const { command } = defineProps<{
|
||||
command: ComfyCommand
|
||||
}>()
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
describe('InfoButton', () => {
|
||||
describe(InfoButton.__name ?? 'InfoButton', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const props = defineProps<{
|
||||
const { nodeId } = defineProps<{
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
@@ -30,7 +30,7 @@ const formattedText = computed(() => {
|
||||
const src = modelValue.value
|
||||
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
|
||||
const tokens: { label: string; url: string }[] = []
|
||||
const holed = src.replace(
|
||||
const holed = src.replaceAll(
|
||||
/\[\[([^|\]]+)\|([^\]]+)\]\]/g,
|
||||
(_m, label, url) => {
|
||||
tokens.push({ label: String(label), url: String(url) })
|
||||
@@ -42,10 +42,10 @@ const formattedText = computed(() => {
|
||||
let html = nl2br(linkifyHtml(holed))
|
||||
|
||||
// Restore placeholders as <a>...</a> (minimal escaping + http default)
|
||||
html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
|
||||
html = html.replaceAll(/__LNK(\d+)__/g, (_m, i) => {
|
||||
const { label, url } = tokens[+i]
|
||||
const safeHref = url.replace(/"/g, '"')
|
||||
const safeLabel = label.replace(/</g, '<').replace(/>/g, '>')
|
||||
const safeHref = url.replaceAll('"', '"')
|
||||
const safeLabel = label.replaceAll('<', '<').replaceAll('>', '>')
|
||||
return /^https?:\/\//i.test(url)
|
||||
? `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`
|
||||
: safeLabel
|
||||
@@ -58,7 +58,7 @@ let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId
|
||||
parentNodeId = nodeId
|
||||
})
|
||||
|
||||
// Watch for either a new node has starting execution or overall execution ending
|
||||
|
||||
@@ -593,11 +593,11 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
})
|
||||
|
||||
await rebootComfyUI()
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
describe('HoneyToast', () => {
|
||||
describe(HoneyToast.__name ?? 'HoneyToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
@@ -110,7 +110,7 @@ import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const props = defineProps<{
|
||||
const { nodeId } = defineProps<{
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
@@ -142,5 +142,5 @@ const {
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd
|
||||
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
|
||||
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue })
|
||||
</script>
|
||||
|
||||
@@ -170,7 +170,7 @@ const getLabel = (val: string | null | undefined) => {
|
||||
|
||||
// Extract complex style logic from template
|
||||
const optionStyle = computed(() => {
|
||||
if (!popoverMinWidth && !popoverMaxWidth) return undefined
|
||||
if (!popoverMinWidth && !popoverMaxWidth) return
|
||||
|
||||
const styles: string[] = []
|
||||
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
|
||||
|
||||
@@ -84,24 +84,24 @@ import { app } from '@/scripts/app'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
|
||||
function isComponentWidget(
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
): widget is ComponentWidget<string[]> {
|
||||
return 'node' in widget && widget.node !== undefined
|
||||
w: ComponentWidget<string[]> | SimplifiedWidget
|
||||
): w is ComponentWidget<string[]> {
|
||||
return 'node' in w && w.node !== undefined
|
||||
}
|
||||
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
if (isComponentWidget(props.widget)) {
|
||||
node.value = props.widget.node
|
||||
} else if (props.nodeId) {
|
||||
if (isComponentWidget(widget)) {
|
||||
node.value = widget.node
|
||||
} else if (nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
|
||||
node.value = app.rootGraph?.getNodeById(nodeId!) || null
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,14 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
initializeLoad3d,
|
||||
cleanup,
|
||||
loading,
|
||||
loadingMessage,
|
||||
onModelDrop,
|
||||
isPreview
|
||||
} = defineProps<{
|
||||
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
|
||||
cleanup: () => void
|
||||
loading: boolean
|
||||
@@ -53,20 +60,20 @@ function focusContainer() {
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
if (props.onModelDrop) {
|
||||
await props.onModelDrop(file)
|
||||
if (onModelDrop) {
|
||||
await onModelDrop(file)
|
||||
}
|
||||
},
|
||||
disabled: computed(() => props.isPreview)
|
||||
disabled: computed(() => isPreview)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
void props.initializeLoad3d(container.value)
|
||||
void initializeLoad3d(container.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
props.cleanup()
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -110,7 +110,7 @@ import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
const { node, modelUrl } = defineProps<{
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
}>()
|
||||
@@ -120,11 +120,10 @@ const containerRef = ref<HTMLDivElement>()
|
||||
const maximized = ref(false)
|
||||
const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
const isStandaloneMode = !props.node && props.modelUrl
|
||||
const isStandaloneMode = !node && modelUrl
|
||||
|
||||
// Use sync version since useLoad3dViewer is already imported (module is loaded)
|
||||
const viewer = props.node
|
||||
? useLoad3dService().getOrCreateViewerSync(toRaw(props.node), useLoad3dViewer)
|
||||
const viewer = node
|
||||
? useLoad3dService().getOrCreateViewerSync(toRaw(node), useLoad3dViewer)
|
||||
: useLoad3dViewer()
|
||||
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
@@ -138,10 +137,10 @@ const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
onMounted(async () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
if (isStandaloneMode && props.modelUrl) {
|
||||
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
||||
} else if (props.node) {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (isStandaloneMode && modelUrl) {
|
||||
await viewer.initializeStandaloneViewer(containerRef.value, modelUrl)
|
||||
} else if (node) {
|
||||
const source = useLoad3dService().getLoad3d(node)
|
||||
if (source) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
|
||||
'backgroundRenderMode'
|
||||
)
|
||||
|
||||
defineProps<{
|
||||
const { hasBackgroundImage, disableBackgroundUpload } = defineProps<{
|
||||
hasBackgroundImage?: boolean
|
||||
disableBackgroundUpload?: boolean
|
||||
}>()
|
||||
|
||||
@@ -22,10 +22,10 @@ const currentPanelComponent = computed<Component>(() => {
|
||||
|
||||
if (tool === Tools.MaskBucket) {
|
||||
return PaintBucketSettingsPanel
|
||||
} else if (tool === Tools.MaskColorFill) {
|
||||
return ColorSelectSettingsPanel
|
||||
} else {
|
||||
return BrushSettingsPanel
|
||||
}
|
||||
if (tool === Tools.MaskColorFill) {
|
||||
return ColorSelectSettingsPanel
|
||||
}
|
||||
return BrushSettingsPanel
|
||||
})
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user