mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
Compare commits
6 Commits
codex/part
...
jaewon/mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5528cbf564 | ||
|
|
832d48eac1 | ||
|
|
630ae61132 | ||
|
|
179c1dd3d8 | ||
|
|
d791351a1c | ||
|
|
922be31b74 |
2
.github/workflows/ci-perf-report.yaml
vendored
2
.github/workflows/ci-perf-report.yaml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/ci-tests-e2e.yaml
vendored
4
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/ci-website-e2e.yaml
vendored
2
.github/workflows/ci-website-e2e.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -172,7 +172,7 @@ test.describe('Topbar commands', () => {
|
||||
type: 'boolean',
|
||||
defaultValue: true
|
||||
},
|
||||
selector: '.p-toggleswitch.p-component'
|
||||
selector: 'button[role="switch"]'
|
||||
},
|
||||
{
|
||||
config: {
|
||||
|
||||
@@ -31,9 +31,7 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
/* props */
|
||||
}
|
||||
args: {/* props */}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
9447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
19
src/components/rightSidePanel/settings/FieldSwitch.test.ts
Normal file
19
src/components/rightSidePanel/settings/FieldSwitch.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
58
src/components/ui/switch/Switch.stories.ts
Normal file
58
src/components/ui/switch/Switch.stories.ts
Normal 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 }
|
||||
}
|
||||
75
src/components/ui/switch/Switch.test.ts
Normal file
75
src/components/ui/switch/Switch.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
49
src/components/ui/switch/Switch.vue
Normal file
49
src/components/ui/switch/Switch.vue
Normal 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>
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
79
src/platform/support/feedbackDialog.test.ts
Normal file
79
src/platform/support/feedbackDialog.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
25
src/platform/support/feedbackDialog.ts
Normal file
25
src/platform/support/feedbackDialog.ts
Normal 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 } : {}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
14
src/platform/surveys/TypeformDialogContent.vue
Normal file
14
src/platform/surveys/TypeformDialogContent.vue
Normal 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>
|
||||
75
src/platform/surveys/TypeformEmbed.test.ts
Normal file
75
src/platform/surveys/TypeformEmbed.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
48
src/platform/surveys/TypeformEmbed.vue
Normal file
48
src/platform/surveys/TypeformEmbed.vue
Normal 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>
|
||||
43
src/platform/surveys/openTypeformDialog.test.ts
Normal file
43
src/platform/surveys/openTypeformDialog.test.ts
Normal 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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
29
src/platform/surveys/openTypeformDialog.ts
Normal file
29
src/platform/surveys/openTypeformDialog.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user