Compare commits

...

6 Commits

Author SHA1 Message Date
dante01yoon
5528cbf564 refactor: migrate app toggles to shared switch 2026-07-18 09:22:47 +09:00
ShihChi Huang
832d48eac1 feat: 1/x add partner node governance feature flag (#13765)
## Summary

Adds the rollout eligibility flag for workspace partner-node governance.

## Changes

- Adds `partner_node_governance_enabled` to remote config typing.
- Exposes `partnerNodeGovernanceEnabled` through `useFeatureFlags`,
defaulting to `false`.

## Impact

This PR does not load policy or change node behavior. The policy API and
store are stacked in #13743.

## Validation

- `pnpm test:unit src/composables/useFeatureFlags.test.ts` (26 tests)
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`
- `pnpm knip`

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Additive feature-flag plumbing with safe defaults; no runtime behavior
or policy changes in this PR.
> 
> **Overview**
> Introduces **`partner_node_governance_enabled`** as a workspace
rollout eligibility switch for upcoming partner-node governance work.
This change only wires the flag; it does not load policy or alter node
behavior (follow-up PRs handle that).
> 
> **Remote config** gains optional `partner_node_governance_enabled`.
**`useFeatureFlags`** exposes **`partnerNodeGovernanceEnabled`**,
resolved via the usual `resolveFlag` path (dev override → remote config
→ server feature) with a default of **`false`**. Unit tests cover the
remote-config-on and default-off cases.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6d862f60c8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-17 21:27:20 +00:00
Alexander Brown
630ae61132 chore: update core dev dependencies (#13733)
## Summary

Bump core dev toolchain (Vue/Vite/Oxlint/Oxfmt/ESLint/pnpm + ecosystem)
to latest same-major versions, with the small compat fixes the new
versions require.

## Changes

- **What**: Catalog bumps in `pnpm-workspace.yaml`: vue 3.5.39, vite
8.1.4, oxlint 1.74, oxfmt 0.59, eslint 10.7, vitest 4.1.10, vue-tsc
3.3.7, tailwindcss 4.3.2, @playwright/test 1.61.1, storybook 10.5.0,
knip 6.27, typescript-eslint 8.64, @vueuse 14.3 + assorted ecosystem
minors. `packageManager` pnpm 11.3.0 → 11.13.1.
- **Compat fixes**:
- `defineModel` default-factory param typing in
`WidgetSelectDefault.vue`/`WidgetSelectDropdown.vue` (vue 3.5.40+ types
it as `Record<string, unknown>`)
- `useClipboard` mock in `useCopyToClipboard.test.ts` (vueuse 14.3:
`ComputedRef` fields + new `copyPending`)
- Removed two knip ignores obsoleted by knip 6.27 (config treats hints
as errors)
- Renamed invalid CSS ident `--color-dark-elevation-1.5` → `-1-5` (dot
is not a valid ident char; unused; oxfmt 0.59 hard-errors on it)
  - oxfmt 0.59 reformat as a separate commit (small churn, 8 files)
- **Held back**: happy-dom pinned at 20.9.0 — 20.10.x throws sync
`NotSupportedError` on external `<script>` append, breaking GTM/Typeform
loader tests. Majors deferred to individual PRs: typescript 7, pinia 4,
vue-router 5, vue-i18n 11, stylelint 17, jsdom 29, globals 17,
unplugin-* majors, vite-plugin-dts 5, @types/node 25.
- **Dependencies**: version bumps only, no new packages. no
`minimumReleaseAgeExclude` exemptions — range floors sit at the newest
age-compliant releases (vue 3.5.40, vite 8.1.5, tailwind 4.3.3,
storybook 10.5.2 are <24h old and flow in automatically once they pass
the gate).

## Review Focus

- Unit suite failures match main exactly (previewAny/uploadAudio
pre-existing flakes); typecheck, lint, knip, build all green. Desktop-ui
typecheck failures are identical on main (pre-existing, untouched).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-17 20:46:09 +00:00
pythongosssss
179c1dd3d8 feat: update typeform feedback to be an in app dialog (#12961)
## Summary

The current typeform feedback link takes the user out of the
application, resulting in a worse user experience. This updates the
feedback form to be embedded in a dialog within the app.

## Changes

- **What**: 
- Extract TypeformEmbed from existing popovers
- Add new dialog content that uses this to display embedded form
- Update feedback button to show dialog

## Screenshots (if applicable)

<img width="1501" height="1315" alt="image"
src="https://github.com/user-attachments/assets/07205aec-7863-4be0-a71e-24f5ed9b7766"
/>

---------

Co-authored-by: Austin Mroz <austin@comfy.org>
2026-07-17 20:40:15 +00:00
Maanil Verma
d791351a1c feat(subscription): unify credit gold across indicators and subscribe buttons (#13763)
## Summary

Credit indicators and subscribe CTAs now share one gold, so the credit
balance, the amounts in pricing and checkout, and the buttons that
upgrade or top up all read as a single system. The subscribe buttons
also drop the old purple gradient for a solid gold that is actually
legible.

## Changes

**What**

- Recolor the credit accent to a single gold value and point every
credit surface at that one token, replacing the scattered amber and gold
used before.
- Repaint the subscribe buttons: they were a multi layer purple gradient
with faint white text (nearly unreadable), and are now a solid gold fill
with dark text. Hover dims slightly instead of shifting the gradient.
- Rename the button `subscribe` variant (it used to be called
`gradient`, which stopped being accurate once it became a flat fill) and
delete the now unused gradient token so there is no dead styling left
behind.
- Swap the credit cost glyphs across pricing tables, subscription
success and preview screens, the free tier quota, the partner node
popover, and both user popovers so they match the recolored token rather
than raw amber.
- Built on top of #13712 

**Breaking**

None. The subscribe buttons keep the same public API (the shared
`SubscribeButton` still exposes a `buttonVariant` prop, now typed
`'default' | 'subscribe'`), and no behavior, routing, or telemetry
changes.

## Testing

This is a token and styling change with no logic, so the meaningful
verification is visual plus the standard gates, not new unit tests (a
test asserting a hex value would just be a change detector).

<img width="507" height="557" alt="image"
src="https://github.com/user-attachments/assets/fed73d12-e3cc-46a6-aae2-db37f24d9bbb"
/>
2026-07-18 00:37:26 +05:30
Mobeen Abdullah
922be31b74 feat(website): rename Comfy Hub to Comfy Workflows in user-facing copy (#13718)
## Summary

Marketing-site half of the **Comfy Hub → Comfy Workflows** rename
([GTM-271](https://linear.app/comfyorg/issue/GTM-271/comfy-workflows-rename-site-docs-copylogo-sweep));
the hub-side sweep is Comfy-Org/workflow_templates#1015. Silent copy
change, links keep pointing at comfy.org/workflows.

## Changes

- **What**: renames the six user-facing "Comfy Hub" mentions to "Comfy
Workflows": the Community nav item, the homepage/models showcase card
title, the models-page "COMFY HUB" eyebrow, the "Browse Comfy Hub" link
(en + zh-CN), and the two supported-models sentences (body copy + FAQ
JSON-LD text).
- Internal identifiers unchanged per the rename PRD non-goals (the
`nav.comfyHub` i18n key stays).

## Review Focus

- The supported-models copy also feeds the FAQPage JSON-LD;
`validate:jsonld` passes on all 503 pages.
- zh-CN strings keep the brand in Latin script ("Comfy Workflows"),
matching how "Comfy Hub" was handled.

## Verification

- typecheck 0 errors · unit 199/199 · oxfmt clean · build 500 pages ·
validate:jsonld 503 pages · built HTML contains zero remaining "Comfy
Hub".

## Screenshots (if applicable)

Attached below (Community nav dropdown, models-page showcase section).
2026-07-17 18:19:41 +00:00
77 changed files with 5863 additions and 4949 deletions

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -63,7 +63,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,7 +28,7 @@ jobs:
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
timeout-minutes: 15
permissions:
contents: read

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,7 +14,7 @@ jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
timeout-minutes: 15
permissions:
contents: write
@@ -128,6 +128,9 @@ jobs:
- name: Remove label
if: always() && github.event_name == 'pull_request'
# Label cleanup is best-effort; the restricted pull_request token may
# lack permission to remove labels and must not fail the run.
continue-on-error: true
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}

View File

@@ -83,8 +83,8 @@ const translations = {
'如果您是 ComfyUI 新手,可以从 App 模式开始——这是工作流的简化视图。您随时可以切换回节点图视图以深入了解。'
},
'showcase.feature3.title': {
en: 'Community Workflows on Comfy Hub',
'zh-CN': 'Comfy Hub 上的社区工作流'
en: 'Community Workflows on Comfy Workflows',
'zh-CN': 'Comfy Workflows 上的社区工作流'
},
'showcase.feature3.description': {
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template and customize it to your needs.',
@@ -2272,7 +2272,7 @@ const translations = {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.comfyHub': { en: 'Comfy Workflows', 'zh-CN': 'Comfy Workflows' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
@@ -4302,8 +4302,8 @@ const translations = {
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
en: 'COMFY WORKFLOWS',
'zh-CN': 'COMFY WORKFLOWS'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
@@ -4339,9 +4339,9 @@ const translations = {
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Workflows</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Workflows</a> 查看最新工作流。'
},
// Payment status pages

View File

@@ -44,7 +44,7 @@ const dirDescriptions: Record<string, string> = {
}
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Workflows, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
@@ -87,7 +87,7 @@ const faqPage: JsonLdNode = {
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Workflows. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
},
{

View File

@@ -39,8 +39,7 @@ export class SettingDialog extends BaseDialog {
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').click()
await this.root.locator(`button[role="switch"][id="${id}"]`).click()
}
category(name: string) {

View File

@@ -17,9 +17,7 @@ import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {
/* ... */
}
MyCustomNode: {/* ... */}
})
await page.route('**/api/object_info', (route) =>

View File

@@ -45,7 +45,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
await settingRow.locator('.p-toggleswitch').click()
await settingRow.getByRole('switch').click()
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))

View File

@@ -172,7 +172,7 @@ test.describe('Topbar commands', () => {
type: 'boolean',
defaultValue: true
},
selector: '.p-toggleswitch.p-component'
selector: 'button[role="switch"]'
},
{
config: {

View File

@@ -31,9 +31,7 @@ export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
/* props */
}
args: {/* props */}
}
```

View File

@@ -37,10 +37,8 @@ const config: KnipConfig = {
entry: ['src/scripts/**/*.ts']
}
},
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -97,7 +97,7 @@
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
"@vueuse/router": "^14.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
@@ -111,7 +111,7 @@
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"fuse.js": "7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
@@ -219,5 +219,5 @@
"node": ">=25 <26",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.3.0"
"packageManager": "pnpm@11.13.1"
}

View File

@@ -49,7 +49,7 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-credit: #ddbd31;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
@@ -78,9 +78,9 @@
--color-interface-panel-job-progress-border: var(--base-foreground);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b / 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b / 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b / 0.4);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
@@ -107,7 +107,7 @@
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-1-5: rgba(from white r g b / 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@@ -140,26 +140,6 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--subscription-button-gradient:
linear-gradient(
315deg,
rgb(105 230 255 / 0.15) 0%,
rgb(99 73 233 / 0.5) 100%
),
radial-gradient(
70.71% 70.71% at 50% 50%,
rgb(62 99 222 / 0.15) 0.01%,
rgb(66 0 123 / 0.5) 100%
),
linear-gradient(
92deg,
#d000ff 0.38%,
#b009fe 37.07%,
#3e1ffc 65.17%,
#009dff 103.86%
),
linear-gradient(var(--color-button-surface, #2d2e32));
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);
@@ -1873,9 +1853,9 @@ audio.comfy-audio.empty-audio-widget {
/* Shadow Cover TOP */
linear-gradient(--value(--color-*) 30%, transparent) center top,
/* Shadow Cover BOTTOM */
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
/* Shadow TOP */
radial-gradient(
radial-gradient(
farthest-side at 50% 0,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
@@ -1883,7 +1863,7 @@ audio.comfy-audio.empty-audio-widget {
)
center top,
/* Shadow BOTTOM */
radial-gradient(
radial-gradient(
farthest-side at 50% 100%,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,

9447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,26 +24,26 @@ catalog:
'@iconify/tailwind4': ^1.2.3
'@iconify/tools': ^5.0.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.1
'@lobehub/i18n-cli': ^1.26.1
'@lucide/vue': ^1.17.0
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@playwright/test': ^1.61.1
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
'@primevue/core': ^4.2.5
'@primevue/forms': ^4.2.5
'@primevue/core': 4.2.5
'@primevue/forms': 4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@primevue/themes': 4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^2.1.0
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-docs': ^10.5.0
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.3.0
'@storybook/vue3': ^10.5.0
'@storybook/vue3-vite': ^10.5.0
'@tailwindcss/vite': ^4.3.2
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
@@ -64,11 +64,11 @@ catalog:
'@types/three': ^0.184.1
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@vitejs/plugin-vue': ^6.0.8
'@vitest/coverage-v8': ^4.1.10
'@vitest/ui': ^4.1.10
'@vueuse/core': ^14.3.0
'@vueuse/integrations': ^14.3.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^6.4.2
@@ -76,75 +76,75 @@ catalog:
class-variance-authority: ^0.7.1
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.4.5
dompurify: 3.4.7
dotenv: ^16.4.5
eslint: ^10.4.0
eslint: ^10.7.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-import-resolver-typescript: ^4.4.5
eslint-plugin-better-tailwindcss: ^4.6.1
eslint-plugin-import-x: ^4.17.1
eslint-plugin-oxlint: 1.73.0
eslint-plugin-playwright: ^2.10.5
eslint-plugin-storybook: ^10.5.0
eslint-plugin-testing-library: ^7.16.2
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
eslint-plugin-vue: ^10.9.2
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.8.9
happy-dom: 20.9.0
husky: ^9.1.7
jiti: 2.6.1
jiti: 2.7.0
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.15.0
knip: ^6.27.0
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
minisearch: ^7.2.0
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
oxfmt: ^0.59.0
oxlint: ^1.74.0
oxlint-tsgolint: ^0.24.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
primevue: 4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
storybook: ^10.5.0
stylelint: ^16.26.1
tailwindcss: ^4.3.0
tailwindcss: ^4.3.2
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.60.0
typescript-eslint: ^8.64.0
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.13
vite: ^8.1.4
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.1.0
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vite-plugin-vue-devtools: ^8.1.5
vitest: ^4.1.10
vue: ^3.5.39
vue-component-type-helpers: ^3.3.7
vue-eslint-parser: ^10.4.1
vue-i18n: ^9.14.5
vue-router: ^4.4.3
vue-tsc: ^3.2.5
vue-tsc: ^3.3.7
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27

View File

@@ -32,11 +32,11 @@ let _runWhenIdle: (
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: GlobalWindow = globalThis as GlobalWindow

View File

@@ -33,7 +33,6 @@
import InputNumber from 'primevue/inputnumber'
import InputText from 'primevue/inputtext'
import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import { markRaw } from 'vue'
import type { Component } from 'vue'
@@ -45,6 +44,7 @@ import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
import InputKnob from '@/components/common/InputKnob.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import type { FormItem } from '@/platform/settings/types'
const formValue = defineModel<unknown>('formValue')
@@ -91,7 +91,7 @@ function getFormComponent(item: FormItem): Component {
}
switch (item.type) {
case 'boolean':
return ToggleSwitch
return Switch
case 'number':
return InputNumber
case 'slider':

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import FieldSwitch from './FieldSwitch.vue'
describe('FieldSwitch', () => {
it('uses its visible label as the switch accessible name', () => {
render(FieldSwitch, {
props: { label: 'Enable preview' },
global: {
directives: { tooltip: {} }
}
})
expect(
screen.getByRole('switch', { name: 'Enable preview' })
).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import Switch from '@/components/ui/switch/Switch.vue'
import LayoutField from './LayoutField.vue'
@@ -13,8 +13,9 @@ const modelValue = defineModel<boolean>({ default: false })
<template>
<LayoutField singleline :label :tooltip>
<ToggleSwitch
<Switch
v-model="modelValue"
:aria-label="label"
class="transition-transform active:scale-90"
/>
</LayoutField>

View File

@@ -76,24 +76,10 @@
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
<ToggleSwitch
<Switch
v-model="nodes2Enabled"
class="ml-4"
:aria-label="item.label"
:pt="{
root: {
style: {
width: '38px',
height: '20px'
}
},
handle: {
style: {
width: '16px',
height: '16px'
}
}
}"
@click.stop
@update:model-value="onNodes2ToggleChange"
/>
@@ -107,11 +93,11 @@ import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingPanelType } from '@/platform/settings/types'

View File

@@ -25,7 +25,7 @@
ref="feedbackRef"
data-testid="feedback-embed"
data-tf-auto-resize
:data-tf-widget="typeformId"
:data-tf-widget="APP_MODE_FEEDBACK_TYPEFORM_ID"
/>
</Popover>
<SidebarIcon
@@ -68,7 +68,7 @@ const sidebarOnLeft = computed(
)
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
const { typeformError, isValidTypeformId } = useTypeformEmbed(
feedbackRef,
APP_MODE_FEEDBACK_TYPEFORM_ID
)

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
@@ -48,7 +48,7 @@
</Button>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
@@ -72,7 +72,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
button-variant="gradient"
button-variant="subscribe"
@subscribed="handleSubscribed"
/>
</div>

View File

@@ -2,7 +2,7 @@
<Button
v-if="isCloud && isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -36,7 +36,15 @@ vi.mock('@/platform/settings/settingStore', () => ({
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: { value: false } })
useCurrentUser: () => ({
isLoggedIn: { value: false },
userEmail: { value: undefined }
})
}))
const openFeedbackDialog = vi.hoisted(() => vi.fn())
vi.mock('@/platform/support/feedbackDialog', () => ({
openFeedbackDialog
}))
vi.mock('@/composables/useFeatureFlags', () => ({
@@ -132,44 +140,28 @@ function renderComponent() {
}
describe('WorkflowTabs feedback button', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
tabBarLayout.value = 'Default'
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
openFeedbackDialog.mockReset()
})
afterEach(() => {
openSpy.mockRestore()
})
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
it('opens the feedback dialog tagged with topbar source when clicked', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
'_blank',
'noopener,noreferrer'
)
expect(openFeedbackDialog).toHaveBeenCalledWith('topbar')
})
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
it('renders the feedback button on Nightly', () => {
distribution.isNightly = true
const { user } = renderComponent()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
'_blank',
'noopener,noreferrer'
)
expect(screen.getByRole('button', { name: 'Feedback' })).toBeInTheDocument()
})
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {

View File

@@ -120,7 +120,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { openFeedbackDialog } from '@/platform/support/feedbackDialog'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -157,11 +157,7 @@ const isIntegratedTabBar = computed(
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
function openFeedback() {
window.open(
buildFeedbackTypeformUrl('topbar'),
'_blank',
'noopener,noreferrer'
)
openFeedbackDialog('topbar')
}
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -24,8 +24,8 @@ export const buttonVariants = cva({
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
subscribe:
'border-transparent bg-credit text-charcoal-800 hover:opacity-80'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -58,7 +58,7 @@ const variants = [
'base',
'tertiary',
'overlay-white',
'gradient'
'subscribe'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Switch from './Switch.vue'
const meta = {
title: 'Components/Switch',
component: Switch,
tags: ['autodocs'],
argTypes: {
disabled: { control: 'boolean' },
readonly: { control: 'boolean' },
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Switch>
export default meta
type Story = StoryObj<typeof meta>
function renderSwitch(initialValue: boolean) {
function render(args: { disabled?: boolean; readonly?: boolean }) {
return {
components: { Switch },
setup() {
const checked = ref(initialValue)
return { args, checked }
},
template: `
<Switch
v-model="checked"
:disabled="args.disabled"
:readonly="args.readonly"
aria-label="Example switch"
/>
`
}
}
return render
}
export const Default: Story = {
render: renderSwitch(false)
}
export const Checked: Story = {
render: renderSwitch(true)
}
export const Disabled: Story = {
render: renderSwitch(true),
args: { disabled: true }
}
export const Readonly: Story = {
render: renderSwitch(true),
args: { readonly: true }
}

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Switch from './Switch.vue'
describe('Switch', () => {
it('exposes its state and requests the opposite value when clicked', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Switch, {
props: {
modelValue: false,
'onUpdate:modelValue': onUpdate
},
attrs: { 'aria-label': 'Notifications' }
})
const control = screen.getByRole('switch', { name: 'Notifications' })
expect(control).not.toBeChecked()
await user.click(control)
expect(onUpdate).toHaveBeenCalledWith(true)
})
it('prevents interaction while disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Switch, {
props: {
disabled: true,
modelValue: true,
'onUpdate:modelValue': onUpdate
},
attrs: { 'aria-label': 'Notifications' }
})
const control = screen.getByRole('switch', { name: 'Notifications' })
expect(control).toBeDisabled()
await user.click(control)
expect(onUpdate).not.toHaveBeenCalled()
})
it('keeps readonly switches focusable without changing their value', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Switch, {
props: {
modelValue: false,
readonly: true,
'onUpdate:modelValue': onUpdate
},
attrs: { 'aria-label': 'Notifications' }
})
const control = screen.getByRole('switch', { name: 'Notifications' })
expect(control).toHaveAttribute('aria-readonly', 'true')
await user.tab()
expect(control).toHaveFocus()
await user.keyboard('[Space]')
await user.keyboard('[Enter]')
await user.click(control)
expect(onUpdate).not.toHaveBeenCalled()
expect(control).not.toBeChecked()
})
})

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { SwitchRootEmits } from 'reka-ui'
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const {
class: customClass = '',
disabled = false,
modelValue = false,
readonly = false
} = defineProps<{
class?: HTMLAttributes['class']
disabled?: boolean
modelValue?: boolean
readonly?: boolean
}>()
const emit = defineEmits<SwitchRootEmits>()
function updateModelValue(value: boolean) {
if (readonly) return
emit('update:modelValue', value)
}
</script>
<template>
<SwitchRoot
:model-value
:disabled
:aria-readonly="readonly || undefined"
:class="
cn(
'group inline-flex h-6 w-10 shrink-0 cursor-pointer touch-manipulation items-center justify-center rounded-full border-0 bg-transparent p-0 transition-shadow outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-readonly:cursor-default',
customClass
)
"
@update:model-value="updateModelValue"
>
<span
class="pointer-events-none inline-flex h-5 w-9 items-center rounded-full border border-transparent bg-interface-stroke px-0.5 transition-colors group-data-[state=checked]:bg-primary-background"
>
<SwitchThumb
class="pointer-events-none block size-4 rounded-full bg-base-background shadow-sm transition-transform data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0"
/>
</span>
</SwitchRoot>
</template>

View File

@@ -1,17 +1,8 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCopy = vi.fn()
const mockWriteText = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
@@ -22,34 +13,31 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: mockWriteText }
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockCopy.mockResolvedValue(undefined)
mockWriteText.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockWriteText).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
mockWriteText.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
@@ -62,7 +50,7 @@ describe('useCopyToClipboard', () => {
})
it('shows error toast when both modern and legacy fail', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
mockWriteText.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
@@ -73,19 +61,17 @@ describe('useCopyToClipboard', () => {
)
})
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
it('falls through to legacy when clipboard API is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).not.toHaveBeenCalled()
expect(mockWriteText).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })

View File

@@ -1,4 +1,3 @@
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
@@ -20,15 +19,14 @@ function legacyCopy(text: string): boolean {
}
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
if (isSupported.value) {
await copy(text)
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
success = true
}
} catch {

View File

@@ -182,6 +182,30 @@ describe('useFeatureFlags', () => {
})
})
describe('partnerNodeGovernanceEnabled', () => {
afterEach(() => {
remoteConfig.value = {}
})
it('uses the workspace eligibility flag', () => {
remoteConfig.value = { partner_node_governance_enabled: true }
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
})
it('defaults to false when the remote flag is unset', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.partnerNodeGovernanceEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -134,6 +135,13 @@ export function useFeatureFlags() {
cachedTeamWorkspacesEnabled
)
},
get partnerNodeGovernanceEnabled() {
return resolveFlag(
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
remoteConfig.value.partner_node_governance_enabled,
false
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,

View File

@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionBarButton } from '@/types/comfy'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
const registerExtension = vi.hoisted(() => vi.fn())
const openFeedbackDialog = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({
t: (key: string) => key
@@ -24,28 +23,15 @@ vi.mock('@/services/extensionService', () => ({
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
vi.mock('@/platform/support/feedbackDialog', () => ({
openFeedbackDialog
}))
describe('cloudFeedbackTopbarButton', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetModules()
registerExtension.mockReset()
distribution.isCloud = false
distribution.isNightly = false
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
openFeedbackDialog.mockReset()
})
function getRegisteredButtons(): ActionBarButton[] {
@@ -56,22 +42,15 @@ describe('cloudFeedbackTopbarButton', () => {
return extension.actionBarButtons
}
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
it('opens the feedback survey tagged with the action-bar source', async () => {
tabBarLayout.value = 'Legacy'
distribution.isCloud = true
await import('./cloudFeedbackTopbarButton')
const buttons = getRegisteredButtons()
expect(buttons).toHaveLength(1)
buttons[0].onClick?.()
expect(openSpy).toHaveBeenCalledTimes(1)
const [url, target, features] = openSpy.mock.calls[0]
expect(url).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
)
expect(target).toBe('_blank')
expect(features).toBe('noopener,noreferrer')
expect(openFeedbackDialog).toHaveBeenCalledWith('action-bar')
})
it('only registers the action bar button when the tab bar is Legacy', async () => {

View File

@@ -1,6 +1,6 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { openFeedbackDialog } from '@/platform/support/feedbackDialog'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
@@ -9,13 +9,7 @@ const buttons: ActionBarButton[] = [
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(
buildFeedbackTypeformUrl('action-bar'),
'_blank',
'noopener,noreferrer'
)
}
onClick: () => openFeedbackDialog('action-bar')
}
]

View File

@@ -3182,13 +3182,18 @@
"description": "You've been using this feature. Would you take a moment to share your feedback?",
"accept": "Sure, I'll help!",
"notNow": "Not Now",
"dontAskAgain": "Don't Ask Again",
"loadError": "Failed to load survey. Please try again later."
"dontAskAgain": "Don't Ask Again"
},
"errorPanelSurvey": {
"ctaText": "How's the new error panel?",
"ctaButton": "Give feedback"
},
"feedback": {
"title": "Share Feedback"
},
"typeform": {
"loadError": "Failed to load the form. Please try again later."
},
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {

View File

@@ -19,7 +19,9 @@ export const hasNonEmptyValue = (
return true
}
export const isOtherValue = (current: string | string[] | undefined): boolean =>
export const isOtherValue = (
current: string | string[] | undefined
): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (

View File

@@ -147,7 +147,7 @@
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
variant="gradient"
variant="subscribe"
size="lg"
class="w-full font-normal"
@click="handleUpgradeToAddCredits"

View File

@@ -43,7 +43,7 @@ const label = computed(() =>
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-amber-400" />
<i class="icon-[comfy--credits] bg-credit" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">

View File

@@ -123,7 +123,7 @@
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span

View File

@@ -2,7 +2,7 @@
<Button
:size
:disabled="disabled"
:variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
:variant="buttonVariant === 'subscribe' ? 'subscribe' : 'primary'"
:class="cn('font-bold', fluid && 'w-full')"
@click="handleSubscribe"
>
@@ -28,7 +28,7 @@ const {
} = defineProps<{
label?: string
size?: 'sm' | 'lg'
buttonVariant?: 'default' | 'gradient'
buttonVariant?: 'default' | 'subscribe'
fluid?: boolean
disabled?: boolean
}>()

View File

@@ -5,7 +5,7 @@
showDelay: 600
}"
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
variant="gradient"
variant="subscribe"
size="unset"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"

View File

@@ -107,6 +107,7 @@ export type RemoteConfig = {
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number

View File

@@ -69,10 +69,13 @@
<ContextMenu ref="menu" :model="contextMenuItems" />
</template>
<template #body="slotProps">
<ToggleSwitch
v-model="editingEnabledExtensions[slotProps.data.name]"
<Switch
:model-value="editingEnabledExtensions[slotProps.data.name]"
:disabled="extensionStore.isExtensionReadOnly(slotProps.data.name)"
@change="updateExtensionStatus"
:aria-label="slotProps.data.name"
@update:model-value="
(enabled) => setExtensionEnabled(slotProps.data.name, enabled)
"
/>
</template>
</Column>
@@ -88,12 +91,12 @@ import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
@@ -168,6 +171,11 @@ const updateExtensionStatus = async () => {
])
}
async function setExtensionEnabled(name: string, enabled: boolean) {
editingEnabledExtensions.value[name] = enabled
await updateExtensionStatus()
}
const enableAllExtensions = async () => {
extensionStore.extensions.forEach((ext) => {
if (extensionStore.isExtensionReadOnly(ext.name)) return

View File

@@ -50,3 +50,40 @@ describe('buildFeedbackTypeformUrl', () => {
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
})
})
describe('buildFeedbackHiddenFields', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
})
async function build(
source: 'topbar' | 'action-bar' | 'help-center',
extraTags?: Record<string, string>
) {
vi.resetModules()
const { buildFeedbackHiddenFields } = await import('./config')
return buildFeedbackHiddenFields(source, extraTags)
}
it('reflects the build distribution', async () => {
distribution.isNightly = true
expect(await build('action-bar')).toBe(
'distribution=oss-nightly,source=action-bar'
)
})
it('appends extra tags after the base segmentation tags', async () => {
distribution.isCloud = true
expect(await build('topbar', { email: 'user@example.com' })).toBe(
'distribution=ccloud,source=topbar,email=user@example.com'
)
})
it('escapes commas in values so they survive the data-tf-hidden parser', async () => {
distribution.isCloud = true
expect(await build('topbar', { email: 'a,b@example.com' })).toBe(
'distribution=ccloud,source=topbar,email=a\\,b@example.com'
)
})
})

View File

@@ -25,7 +25,17 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
export type FeedbackSource = 'topbar' | 'action-bar' | 'help-center'
export const FEEDBACK_TYPEFORM_ID = 'q7azbWPi'
const FEEDBACK_TYPEFORM_BASE_URL = `https://form.typeform.com/to/${FEEDBACK_TYPEFORM_ID}`
/** Shared by the URL and embed builders so their segmentation tags can't drift. */
function getFeedbackTags(source: FeedbackSource): Record<string, string> {
return { distribution: getDistribution(), source }
}
/**
* Builds the feedback Typeform URL tagged with the current build distribution
@@ -33,16 +43,22 @@ const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
* (Typeform's hidden-field convention) so survey responses can be segmented
* by distribution (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
const params = new URLSearchParams({
distribution: getDistribution(),
source
})
export function buildFeedbackTypeformUrl(source: FeedbackSource): string {
const params = new URLSearchParams(getFeedbackTags(source))
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
export function buildFeedbackHiddenFields(
source: FeedbackSource,
extraTags: Record<string, string> = {}
): string {
// Typeform's `data-tf-hidden` parser (transformRecord) splits on `,` and
// unescapes `\,`, so a comma in a value is the only delimiter that needs escaping.
return Object.entries({ ...getFeedbackTags(source), ...extraTags })
.map(([key, value]) => `${key}=${value.replace(/,/g, '\\,')}`)
.join(',')
}
/**
* Builds the support URL with optional user information for pre-filling.
* Users without login information will still get a valid support URL without pre-fill.

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { openTypeformDialog } from '@/platform/surveys/openTypeformDialog'
import { useTelemetry } from '@/platform/telemetry'
import { FEEDBACK_TYPEFORM_ID } from './config'
import { openFeedbackDialog } from './feedbackDialog'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/surveys/openTypeformDialog', () => ({
openTypeformDialog: vi.fn()
}))
const trackUiButtonClicked = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({ trackUiButtonClicked }))
}))
const userEmail = vi.hoisted((): { value: string | undefined } => ({
value: undefined
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ userEmail })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true,
isNightly: false
}))
describe('openFeedbackDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
userEmail.value = undefined
})
it('opens the feedback form tagged with distribution and source', () => {
openFeedbackDialog('action-bar')
expect(openTypeformDialog).toHaveBeenCalledWith({
key: 'global-feedback',
typeformId: FEEDBACK_TYPEFORM_ID,
title: 'feedback.title',
hiddenFields: 'distribution=ccloud,source=action-bar'
})
})
it('includes the logged-in user email as a hidden field', () => {
userEmail.value = 'user@example.com'
openFeedbackDialog('action-bar')
expect(openTypeformDialog).toHaveBeenCalledWith(
expect.objectContaining({
hiddenFields:
'distribution=ccloud,source=action-bar,email=user@example.com'
})
)
})
it('tracks the button click tagged with the opening source', () => {
openFeedbackDialog('topbar')
expect(trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'feedback_button_clicked',
element_group: 'topbar'
})
})
it('does not throw when telemetry is unavailable', () => {
vi.mocked(useTelemetry).mockReturnValueOnce(null)
expect(() => openFeedbackDialog('action-bar')).not.toThrow()
expect(openTypeformDialog).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,25 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { t } from '@/i18n'
import { openTypeformDialog } from '@/platform/surveys/openTypeformDialog'
import { useTelemetry } from '@/platform/telemetry'
import { FEEDBACK_TYPEFORM_ID, buildFeedbackHiddenFields } from './config'
import type { FeedbackSource } from './config'
export function openFeedbackDialog(source: FeedbackSource) {
const { userEmail } = useCurrentUser()
useTelemetry()?.trackUiButtonClicked({
button_id: 'feedback_button_clicked',
element_group: source
})
openTypeformDialog({
key: 'global-feedback',
typeformId: FEEDBACK_TYPEFORM_ID,
title: t('feedback.title'),
hiddenFields: buildFeedbackHiddenFields(
source,
userEmail.value ? { email: userEmail.value } : {}
)
})
}

View File

@@ -2,9 +2,12 @@ import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const mockIsNightly = vi.hoisted(() => ({ value: true }))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
@@ -21,12 +24,6 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
describe('NightlySurveyPopover', () => {
const defaultConfig = {
featureId: 'test-feature',
@@ -74,6 +71,7 @@ describe('NightlySurveyPopover', () => {
...eventHandlers
},
global: {
plugins: [i18n],
stubs: {
Teleport: true
}
@@ -111,6 +109,19 @@ describe('NightlySurveyPopover', () => {
).not.toBeInTheDocument()
})
it('does not show when typeform id is invalid', async () => {
setFeatureUsage('test-feature', 5)
await renderComponent({ ...defaultConfig, typeformId: 'invalid id!' })
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(
screen.queryByTestId('nightly-survey-popover')
).not.toBeInTheDocument()
})
it('does not show on cloud', async () => {
mockIsCloud.value = true
setFeatureUsage('test-feature', 5)
@@ -182,6 +193,7 @@ describe('NightlySurveyPopover', () => {
open
},
global: {
plugins: [i18n],
stubs: {
Teleport: true
}

View File

@@ -25,18 +25,10 @@
</Button>
</div>
<div v-if="typeformError" class="text-danger text-sm">
{{ t('nightlySurvey.loadError') }}
<div class="min-h-75">
<TypeformEmbed :typeform-id="config.typeformId" auto-resize />
</div>
<div
v-show="!typeformError && isValidTypeformId"
ref="typeformRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
class="min-h-[300px]"
/>
<div
class="mt-3 flex items-center gap-2"
:class="mode === 'eligible' ? 'justify-center' : 'justify-end'"
@@ -59,14 +51,15 @@
</template>
<script setup lang="ts">
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TypeformEmbed from './TypeformEmbed.vue'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
import { useSurveyEligibility } from './useSurveyEligibility'
import { useTypeformEmbed } from './useTypeformEmbed'
import { isTypeformIdValid } from './useTypeformEmbed'
const { config, mode = 'eligible' } = defineProps<{
config: FeatureSurveyConfig
@@ -95,12 +88,6 @@ const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
)
const hasOpenedOnce = ref(openModel.value)
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
typeformRef,
() => config.typeformId
)
// Teleport stays mounted after the first open so the Typeform iframe
// persists across consumer-side lifecycle changes.
@@ -126,7 +113,7 @@ watch(
showTimeout = setTimeout(() => {
showTimeout = null
if (!isValidTypeformId.value) return
if (!isTypeformIdValid(config.typeformId)) return
if (openModel.value) return
openModel.value = true
markSurveyShown()

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import TypeformEmbed from './TypeformEmbed.vue'
const { typeformId, hiddenFields } = defineProps<{
typeformId: string
hiddenFields?: string
}>()
</script>
<template>
<div class="h-[70vh] min-h-96">
<TypeformEmbed :typeform-id :hidden-fields redirect-target="_self" />
</div>
</template>

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import TypeformEmbed from './TypeformEmbed.vue'
const embedState = vi.hoisted(() => ({
typeformError: false,
isValidTypeformId: true
}))
vi.mock('./useTypeformEmbed', () => ({
useTypeformEmbed: vi.fn(() => ({
typeformError: ref(embedState.typeformError),
isValidTypeformId: ref(embedState.isValidTypeformId)
}))
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function renderEmbed(props: ComponentProps<typeof TypeformEmbed>) {
return render(TypeformEmbed, { props, global: { plugins: [i18n] } })
}
describe('TypeformEmbed', () => {
beforeEach(() => {
embedState.typeformError = false
embedState.isValidTypeformId = true
})
it('forwards hidden fields and leaves redirect target to Typeform by default', () => {
renderEmbed({ typeformId: 'abc123', hiddenFields: 'source=topbar' })
const embed = screen.getByTestId('typeform-embed')
expect(embed).toHaveAttribute('data-tf-widget', 'abc123')
expect(embed).toHaveAttribute('data-tf-hidden', 'source=topbar')
expect(embed).not.toHaveAttribute('data-tf-redirect-target')
expect(embed).not.toHaveAttribute('data-tf-auto-resize')
})
it('keeps redirect-on-completion inside the iframe when requested', () => {
renderEmbed({ typeformId: 'abc123', redirectTarget: '_self' })
expect(screen.getByTestId('typeform-embed')).toHaveAttribute(
'data-tf-redirect-target',
'_self'
)
})
it('enables auto-resize when requested', () => {
renderEmbed({ typeformId: 'abc123', autoResize: true })
expect(screen.getByTestId('typeform-embed')).toHaveAttribute(
'data-tf-auto-resize'
)
})
it('shows the load-error message instead of the embed when the script fails', () => {
embedState.typeformError = true
renderEmbed({ typeformId: 'abc123' })
expect(screen.getByText('typeform.loadError')).toBeInTheDocument()
expect(screen.queryByTestId('typeform-embed')).toBeNull()
})
it('shows the load-error message instead of the embed for an invalid form id', () => {
embedState.isValidTypeformId = false
renderEmbed({ typeformId: 'bad id!' })
expect(screen.getByText('typeform.loadError')).toBeInTheDocument()
expect(screen.queryByTestId('typeform-embed')).toBeNull()
})
})

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTypeformEmbed } from './useTypeformEmbed'
const {
typeformId,
hiddenFields,
autoResize = false,
redirectTarget
} = defineProps<{
typeformId: string
/** Comma-separated `key=value` tags passed to Typeform via `data-tf-hidden`. */
hiddenFields?: string
autoResize?: boolean
/** `_self` keeps a form's completion redirect inside the iframe, never navigating the host app. */
redirectTarget?: '_self' | '_blank'
}>()
const { t } = useI18n()
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
const { typeformError, isValidTypeformId } = useTypeformEmbed(
typeformRef,
() => typeformId
)
</script>
<template>
<div
v-if="typeformError || !isValidTypeformId"
class="text-danger flex h-full items-center text-sm"
>
{{ t('typeform.loadError') }}
</div>
<!-- `data-tf-auto-resize` is read by presence, so it must be absent (not "false") when disabled -->
<div
v-else
ref="typeformRef"
data-testid="typeform-embed"
:data-tf-widget="typeformId"
:data-tf-hidden="hiddenFields"
:data-tf-redirect-target="redirectTarget"
:data-tf-auto-resize="autoResize || undefined"
:class="autoResize ? 'w-full' : 'size-full'"
/>
</template>

View File

@@ -0,0 +1,43 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDialogStore } from '@/stores/dialogStore'
import TypeformDialogContent from './TypeformDialogContent.vue'
import { openTypeformDialog } from './openTypeformDialog'
describe('openTypeformDialog', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('opens the form embed in a Reka dialog with the given id and hidden fields', () => {
const showDialog = vi.spyOn(useDialogStore(), 'showDialog')
openTypeformDialog({
typeformId: 'abc123',
title: 'A Form',
hiddenFields: 'foo=bar'
})
expect(showDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'typeform-abc123',
title: 'A Form',
component: TypeformDialogContent,
props: { typeformId: 'abc123', hiddenFields: 'foo=bar' },
dialogComponentProps: expect.objectContaining({ renderer: 'reka' })
})
)
})
it('honors an explicit dialog key', () => {
const showDialog = vi.spyOn(useDialogStore(), 'showDialog')
openTypeformDialog({ typeformId: 'abc123', title: 'A Form', key: 'custom' })
expect(showDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'custom' })
)
})
})

View File

@@ -0,0 +1,29 @@
import { useDialogStore } from '@/stores/dialogStore'
import TypeformDialogContent from './TypeformDialogContent.vue'
export interface TypeformDialogOptions {
typeformId: string
title: string
/** Comma-separated `key=value` tags passed to Typeform via `data-tf-hidden`. */
hiddenFields?: string
key?: string
}
export function openTypeformDialog({
typeformId,
title,
hiddenFields,
key
}: TypeformDialogOptions) {
useDialogStore().showDialog({
key: key ?? `typeform-${typeformId}`,
title,
component: TypeformDialogContent,
props: { typeformId, hiddenFields },
dialogComponentProps: {
renderer: 'reka',
size: 'lg'
}
})
}

View File

@@ -24,7 +24,6 @@ describe('useTypeformEmbed', () => {
const result = runInScope(() => useTypeformEmbed(containerRef, 'goZLqjKL'))
expect(result.isValidTypeformId.value).toBe(true)
expect(result.typeformId.value).toBe('goZLqjKL')
})
it('marks ids with non-alphanumeric characters as invalid', () => {
@@ -34,7 +33,6 @@ describe('useTypeformEmbed', () => {
)
expect(result.isValidTypeformId.value).toBe(false)
expect(result.typeformId.value).toBe('')
})
it('marks undefined id as invalid', () => {
@@ -42,7 +40,6 @@ describe('useTypeformEmbed', () => {
const result = runInScope(() => useTypeformEmbed(containerRef, undefined))
expect(result.isValidTypeformId.value).toBe(false)
expect(result.typeformId.value).toBe('')
})
it('marks empty string id as invalid', () => {
@@ -50,7 +47,6 @@ describe('useTypeformEmbed', () => {
const result = runInScope(() => useTypeformEmbed(containerRef, ''))
expect(result.isValidTypeformId.value).toBe(false)
expect(result.typeformId.value).toBe('')
})
it('exposes a reactive typeformError ref initialized to false', () => {

View File

@@ -52,10 +52,6 @@ export function useTypeformEmbed(
isTypeformIdValid(toValue(typeformIdInput))
)
const typeformId = computed(() =>
isValidTypeformId.value ? (toValue(typeformIdInput) ?? '') : ''
)
whenever(typeformRef, async () => {
try {
await ensureScriptLoaded()
@@ -72,7 +68,6 @@ export function useTypeformEmbed(
return {
typeformError,
isValidTypeformId,
typeformId
isValidTypeformId
}
}

View File

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
@@ -82,7 +82,7 @@
<!-- Upgrade to add credits (free tier) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
@@ -110,7 +110,7 @@
: $t('workspaceSwitcher.subscribe')
"
size="sm"
button-variant="gradient"
button-variant="subscribe"
/>
<Button
v-if="

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>

View File

@@ -38,7 +38,7 @@
{{ $t(creditsRefillLabelKey) }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span class="font-bold text-base-foreground">
{{ refillCredits }}
</span>

View File

@@ -28,7 +28,7 @@
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
</div>

View File

@@ -66,7 +66,7 @@
<div class="flex items-center justify-between">
<span class="text-base-foreground">{{ refillLabel }}</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span class="font-bold text-base-foreground">{{
refillCredits
}}</span>
@@ -89,7 +89,7 @@
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
<span class="font-bold text-base-foreground">{{
monthlyRefillCredits
}}</span>

View File

@@ -166,7 +166,7 @@
<div class="mt-auto flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span
@@ -244,7 +244,7 @@
<div class="flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span

View File

@@ -7,7 +7,7 @@ defineProps<{ title: string; price: string }>()
<span
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
>
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
<i class="mr-1 icon-[lucide--component] h-4 bg-credit" />
{{ price }}
</span>
</div>

View File

@@ -20,7 +20,7 @@ const { t } = useI18n()
<Popover v-if="mobile && creditsBadges.length" side="top">
<template #button>
<Button class="mr-2 size-10">
<i class="icon-[comfy--credits] bg-amber-400" />
<i class="icon-[comfy--credits] bg-credit" />
</Button>
</template>
<section
@@ -51,7 +51,7 @@ const { t } = useI18n()
<div class="mb-1 w-full border-b border-border-subtle" />
<CollapsibleTrigger as-child>
<Button variant="textonly" class="w-full text-sm">
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
<i class="icon-[comfy--credits] size-4 bg-credit" />
{{ t('linearMode.hasCreditCost') }}
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
<i v-else class="ml-auto icon-[lucide--chevron-down]" />

View File

@@ -224,9 +224,10 @@ function resolveValues(values: unknown): string[] {
}
const modelValue = defineModel<string | undefined>({
default(modelProps: Props) {
default(modelProps: Record<string, unknown>) {
try {
const values = modelProps.widget.options?.values
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
const resolved = typeof values === 'function' ? values() : values
const firstValue = Array.isArray(resolved)
? resolved.find((value) => value !== null && value !== undefined)

View File

@@ -41,8 +41,9 @@ provide(
)
const modelValue = defineModel<string | undefined>({
default(props: Props) {
const values = props.widget.options?.values
default(modelProps: Record<string, unknown>) {
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
return (Array.isArray(values) ? values[0] : undefined) ?? ''
}
})

View File

@@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { defineComponent } from 'vue'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
@@ -27,17 +26,6 @@ const i18n = createI18n({
}
})
const ToggleSwitchStub = defineComponent({
name: 'ToggleSwitch',
props: {
modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
},
emits: ['update:modelValue'],
template: `<input type="checkbox" role="switch" :checked="modelValue" :disabled="disabled" @change="$emit('update:modelValue', $event.target.checked)" />`
})
describe('WidgetToggleSwitch Value Binding', () => {
const createToggleWidget = (
value: boolean = false,
@@ -58,15 +46,28 @@ describe('WidgetToggleSwitch Value Binding', () => {
onModelUpdate?: (value: boolean | undefined) => void
) => {
const user = userEvent.setup()
const result = render(WidgetToggleSwitch, {
props: {
widget,
modelValue,
...(onModelUpdate ? { 'onUpdate:modelValue': onModelUpdate } : {})
const Wrapper = defineComponent({
components: { WidgetToggleSwitch },
setup() {
const value = ref(modelValue)
function updateValue(nextValue: boolean | undefined) {
if (nextValue === undefined) return
value.value = nextValue
onModelUpdate?.(nextValue)
}
return { updateValue, value, widget }
},
template: `
<WidgetToggleSwitch
:widget="widget"
:model-value="value"
@update:model-value="updateValue"
/>
`
})
const result = render(Wrapper, {
global: {
plugins: [PrimeVue, i18n],
stubs: { ToggleSwitch: ToggleSwitchStub },
plugins: [i18n],
components: { ToggleGroup, ToggleGroupItem }
}
})
@@ -177,7 +178,7 @@ describe('WidgetToggleSwitch Value Binding', () => {
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders ToggleSwitch when no labels are provided', () => {
it('renders Switch when no labels are provided', () => {
const widget = createToggleWidget(false, {})
mountComponent(widget, false)
@@ -285,6 +286,34 @@ describe('WidgetToggleSwitch Value Binding', () => {
}
})
it('keeps an implicit switch unchanged when read_only is set', async () => {
const widget = createToggleWidget(false, { read_only: true })
const onModelUpdate = vi.fn()
const { user } = mountComponent(widget, false, onModelUpdate)
const control = screen.getByRole('switch')
expect(control).toHaveAttribute('aria-readonly', 'true')
await user.click(control)
expect(onModelUpdate).not.toHaveBeenCalled()
expect(control).not.toBeChecked()
})
it('disables an implicit switch when disabled is set', async () => {
const widget = createToggleWidget(false, { disabled: true })
const onModelUpdate = vi.fn()
const { user } = mountComponent(widget, false, onModelUpdate)
const control = screen.getByRole('switch')
expect(control).toBeDisabled()
await user.click(control)
expect(onModelUpdate).not.toHaveBeenCalled()
expect(control).not.toBeChecked()
})
it('does not emit when clicking already-selected option', async () => {
const widget = createToggleWidget(false, { on: 'yes', off: 'no' })
const onModelUpdate = vi.fn()

View File

@@ -1,6 +1,5 @@
<template>
<WidgetLayoutField v-slot="{ borderStyle }" :widget :no-border="!hasLabels">
<!-- Use ToggleGroup when explicit labels are provided -->
<ToggleGroup
v-if="hasLabels"
type="single"
@@ -22,7 +21,6 @@
</ToggleGroupItem>
</ToggleGroup>
<!-- Use ToggleSwitch for implicit boolean states -->
<div
v-else
:class="
@@ -33,9 +31,10 @@
)
"
>
<ToggleSwitch
<Switch
v-model="modelValue"
v-bind="filteredProps"
:disabled="Boolean(widget.options?.disabled)"
:readonly="Boolean(widget.options?.read_only)"
:aria-label="widget.name"
/>
</div>
@@ -43,19 +42,15 @@
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Switch from '@/components/ui/switch/Switch.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@comfyorg/tailwind-utils'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -69,10 +64,6 @@ const modelValue = defineModel<boolean>()
const hideLayoutField = useHideLayoutField()
const { t } = useI18n()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
const hasLabels = computed(() => {
return widget.options?.on != null || widget.options?.off != null
})

View File

@@ -124,7 +124,7 @@ const findElementsInTag = (
end: number,
id: Uint8Array
): EbmlElementRange | null => {
for (let pos = start; pos < end - 1; ) {
for (let pos = start; pos < end - 1;) {
if (matchesId(data, pos, id)) {
const size = readVint(data, pos + 2)
if (size && size.value > 0) {
@@ -307,7 +307,7 @@ const ebmlToString = (
const parseMetadata = (data: Uint8Array): ComfyMetadata => {
const meta: ComfyMetadata = {}
for (let pos = 0; pos < data.length - 2; ) {
for (let pos = 0; pos < data.length - 2;) {
const tagInfo = findNextTag(data, pos)
if (!tagInfo) {
pos++

View File

@@ -306,13 +306,9 @@ export function useNodeFactory() {
// Create node based on type and configuration
switch (type) {
case 'basic':
return {
/* basic node implementation */
}
return {/* basic node implementation */}
case 'complex':
return {
/* complex node implementation */
}
return {/* complex node implementation */}
default:
throw new Error(`Unknown node type: ${type}`)
}
@@ -347,9 +343,7 @@ Services in ComfyUI can be tested effectively using different approaches dependi
```typescript
// Manual instantiation required
const mockData = [
/* test data */
]
const mockData = [/* test data */]
const service = new NodeSearchService(mockData)
```

View File

@@ -147,9 +147,7 @@ describe('useAuthStore', () => {
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
const mockAuth: MockAuth = {
/* mock Auth object */
}
const mockAuth: MockAuth = {/* mock Auth object */}
const mockUser: MockUser = {
uid: 'test-user-id',

View File

@@ -132,9 +132,9 @@ export function fixBadLinks(
// We can set to null (delete), so undefined means we haven't set it at all.
if (patchedNode['inputs']![slot] !== undefined) {
logger.log(
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
'inputs'
]![slot]!} Skipping.`
` > Already set ${node.id}.inputs[${slot}] to ${
patchedNode['inputs']![slot]!
} Skipping.`
)
return false
}

View File

@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -11,7 +11,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('es-toolkit/compat', async () => {
const actual = await vi.importActual('es-toolkit/compat')
return {
@@ -20,20 +19,49 @@ vi.mock('es-toolkit/compat', async () => {
}
})
const ToggleSwitchStub = defineComponent({
name: 'ToggleSwitch',
props: {
modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
ariaLabel: { type: String, default: '' }
const {
acknowledgmentState,
mockMarkConflictsAsSeen,
mockShowImportFailedDialog,
mockShowNodeConflictDialog
} = vi.hoisted(() => ({
acknowledgmentState: {
value: {
modal_dismissed: false,
red_dot_dismissed: false,
warning_banner_dismissed: false
}
},
emits: ['update:modelValue', 'focus'],
template: `<div data-testid="toggle-switch" :data-model-value="String(modelValue)" :data-disabled="String(disabled)">
<button data-testid="toggle-true" @click="$emit('update:modelValue', true)">on</button>
<button data-testid="toggle-false" @click="$emit('update:modelValue', false)">off</button>
</div>`
})
mockMarkConflictsAsSeen: vi.fn(),
mockShowImportFailedDialog: vi.fn(),
mockShowNodeConflictDialog: vi.fn()
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => ({
acknowledgmentState,
markConflictsAsSeen: mockMarkConflictsAsSeen
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useImportFailedDetection',
() => ({
useImportFailedDetection: () => ({
showImportFailedDialog: mockShowImportFailedDialog
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
() => ({
useNodeConflictDialog: () => ({ show: mockShowNodeConflictDialog })
})
)
const mockNodePack = {
id: 'test-pack',
@@ -72,6 +100,8 @@ describe('PackEnableToggle', () => {
mockIsPackEnabled.mockReset()
mockEnablePack.mockReset().mockResolvedValue(undefined)
mockDisablePack.mockReset().mockResolvedValue(undefined)
mockGetConflictsForPackageByID.mockReset().mockReturnValue(undefined)
acknowledgmentState.value.modal_dismissed = false
})
function renderComponent({
@@ -102,10 +132,7 @@ describe('PackEnableToggle', () => {
...props
},
global: {
plugins: [PrimeVue, createTestingPinia({ stubActions: false }), i18n],
stubs: {
ToggleSwitch: ToggleSwitchStub
}
plugins: [PrimeVue, createTestingPinia({ stubActions: false }), i18n]
}
})
}
@@ -114,7 +141,9 @@ describe('PackEnableToggle', () => {
mockIsPackEnabled.mockReturnValue(true)
renderComponent()
expect(screen.getByTestId('toggle-switch')).toBeInTheDocument()
expect(
screen.getByRole('switch', { name: 'Enable or disable pack' })
).toBeInTheDocument()
})
it('checks if pack is enabled on mount', () => {
@@ -128,27 +157,21 @@ describe('PackEnableToggle', () => {
mockIsPackEnabled.mockReturnValue(true)
renderComponent()
expect(screen.getByTestId('toggle-switch')).toHaveAttribute(
'data-model-value',
'true'
)
expect(screen.getByRole('switch')).toBeChecked()
})
it('sets toggle to off when pack is disabled', () => {
mockIsPackEnabled.mockReturnValue(false)
renderComponent()
expect(screen.getByTestId('toggle-switch')).toHaveAttribute(
'data-model-value',
'false'
)
expect(screen.getByRole('switch')).not.toBeChecked()
})
it('calls enablePack when toggle is switched on', async () => {
mockIsPackEnabled.mockReturnValue(false)
renderComponent()
await user.click(screen.getByTestId('toggle-true'))
await user.click(screen.getByRole('switch'))
expect(mockEnablePack).toHaveBeenCalledWith(
expect.objectContaining({
@@ -162,7 +185,7 @@ describe('PackEnableToggle', () => {
mockIsPackEnabled.mockReturnValue(true)
renderComponent()
await user.click(screen.getByTestId('toggle-false'))
await user.click(screen.getByRole('switch'))
expect(mockDisablePack).toHaveBeenCalledWith(
expect.objectContaining({
@@ -173,27 +196,48 @@ describe('PackEnableToggle', () => {
})
it('disables toggle while loading', async () => {
let resolvePendingPromise: () => void
const pendingPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 1000)
resolvePendingPromise = resolve
})
mockEnablePack.mockReturnValue(pendingPromise)
mockIsPackEnabled.mockReturnValue(false)
renderComponent()
await user.click(screen.getByTestId('toggle-true'))
await user.click(screen.getByRole('switch'))
await nextTick()
expect(screen.getByTestId('toggle-switch')).toHaveAttribute(
'data-disabled',
'true'
)
expect(screen.getByRole('switch')).toBeDisabled()
resolvePendingPromise!()
await pendingPromise
await nextTick()
expect(screen.getByTestId('toggle-switch')).toHaveAttribute(
'data-disabled',
'false'
)
expect(screen.getByRole('switch')).not.toBeDisabled()
})
it('opens the conflict dialog when the readonly switch receives focus', async () => {
mockGetConflictsForPackageByID.mockReturnValue({
package_id: 'test-pack',
package_name: 'Test Pack',
has_conflict: true,
conflicts: [
{
type: 'comfyui_version',
current_value: '1.0.0',
required_value: '2.0.0'
}
],
is_compatible: false
})
mockIsPackEnabled.mockReturnValue(true)
renderComponent()
const control = screen.getByRole('switch')
expect(control).toHaveAttribute('aria-readonly', 'true')
await user.tab()
expect(mockShowNodeConflictDialog).toHaveBeenCalledTimes(1)
})
describe('conflict warning icon', () => {

View File

@@ -13,7 +13,7 @@
class="icon-[lucide--triangle-alert] text-xl text-warning-background"
/>
</div>
<ToggleSwitch
<Switch
v-if="!canToggleDirectly"
:model-value="isEnabled"
:disabled="isLoading"
@@ -21,7 +21,7 @@
:aria-label="$t('g.enableOrDisablePack')"
@focus="handleToggleInteraction"
/>
<ToggleSwitch
<Switch
v-else
:model-value="isEnabled"
:disabled="isLoading"
@@ -32,10 +32,10 @@
</template>
<script setup lang="ts">
import { debounce } from 'es-toolkit/compat'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Switch from '@/components/ui/switch/Switch.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'