mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 22:58:08 +00:00
Compare commits
13 Commits
austin/bat
...
sno-deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c0bdf5af7 | ||
|
|
604d9e70f6 | ||
|
|
01a2ca3bcf | ||
|
|
2a466af154 | ||
|
|
553ea63357 | ||
|
|
995ebc4ba4 | ||
|
|
d282353370 | ||
|
|
85ae0a57c3 | ||
|
|
0d64d503ec | ||
|
|
30ef6f2b8c | ||
|
|
6012341fd1 | ||
|
|
a80f6d7922 | ||
|
|
0f5aca6726 |
153
.github/workflows/ci-tests-e2e.yaml
vendored
153
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -1,12 +1,12 @@
|
||||
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
|
||||
name: 'CI: Tests E2E'
|
||||
name: "CI: Tests E2E"
|
||||
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
workflow_dispatch:
|
||||
branches-ignore:
|
||||
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -15,56 +15,65 @@ concurrency:
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup Test Environment, build frontend but do not start server yet
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
|
||||
|
||||
# Upload only built dist/ (containerized test jobs will pnpm install without cache)
|
||||
- name: Upload built frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
# Save the entire workspace as cache for later test jobs to restore
|
||||
- name: Generate cache key
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
path: .
|
||||
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
|
||||
|
||||
# Sharded chromium tests
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
# download built frontend repo from setup job
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
fail-on-cache-miss: true
|
||||
path: .
|
||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
||||
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup nodejs, pnpm, reuse built frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run sharded tests (browsers pre-installed in container)
|
||||
# Run sharded tests and upload sharded reports
|
||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
@@ -72,7 +81,7 @@ jobs:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: blob-report-chromium-${{ matrix.shardIndex }}
|
||||
@@ -84,70 +93,69 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
# download built frontend repo from setup job
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
fail-on-cache-miss: true
|
||||
path: .
|
||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
||||
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup nodejs, pnpm, reuse built frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run tests (browsers pre-installed in container)
|
||||
# Run tests and upload reports
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
|
||||
- name: Generate HTML and JSON reports
|
||||
if: always()
|
||||
run: |
|
||||
# Generate HTML report from blob
|
||||
pnpm exec playwright merge-reports --reporter=html ./blob-report
|
||||
# Generate JSON report separately with explicit output path
|
||||
# Run tests with both HTML and JSON reporters
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright merge-reports --reporter=json ./blob-report
|
||||
pnpm exec playwright test --project=${{ matrix.browser }} \
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.browser }}
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
# Merge sharded test reports (no container needed - only runs CLI)
|
||||
# Merge sharded test reports
|
||||
merge-reports:
|
||||
needs: [playwright-tests-chromium-sharded]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup Test Environment, we only need playwright to merge reports
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./all-blob-reports
|
||||
pattern: blob-report-chromium-*
|
||||
@@ -156,13 +164,13 @@ jobs:
|
||||
- name: Merge into HTML Report
|
||||
run: |
|
||||
# Generate HTML report
|
||||
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
|
||||
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
|
||||
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-chromium
|
||||
path: ./playwright-report/
|
||||
@@ -180,7 +188,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Get start time
|
||||
id: start-time
|
||||
@@ -207,10 +215,10 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download all playwright reports
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
@@ -220,7 +228,6 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
|
||||
69
.github/workflows/ci-tests-storybook.yaml
vendored
69
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -1,9 +1,10 @@
|
||||
# Description: Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
|
||||
name: 'CI: Tests Storybook'
|
||||
name: "CI: Tests Storybook"
|
||||
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -14,8 +15,8 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -36,10 +37,21 @@ jobs:
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
@@ -58,7 +70,7 @@ jobs:
|
||||
|
||||
- name: Upload Storybook build
|
||||
if: success() && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: storybook-static/
|
||||
@@ -75,16 +87,27 @@ jobs:
|
||||
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Required for Chromatic baseline
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Storybook and run Chromatic
|
||||
id: chromatic
|
||||
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
|
||||
uses: chromaui/action@latest
|
||||
with:
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
buildScriptName: build-storybook
|
||||
@@ -114,18 +137,18 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Storybook build
|
||||
if: needs.storybook-build.outputs.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
|
||||
|
||||
- name: Deploy Storybook and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -148,30 +171,30 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Update comment with Chromatic URLs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
|
||||
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
|
||||
|
||||
|
||||
// Find the existing Storybook comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ github.event.pull_request.number }}
|
||||
});
|
||||
|
||||
const storybookComment = comments.find(comment =>
|
||||
|
||||
const storybookComment = comments.find(comment =>
|
||||
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
|
||||
);
|
||||
|
||||
|
||||
if (storybookComment && buildUrl && storybookUrl) {
|
||||
// Append Chromatic info to existing comment
|
||||
const updatedBody = storybookComment.body.replace(
|
||||
/---\n(.*)$/s,
|
||||
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
|
||||
);
|
||||
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
122
.github/workflows/deploy-branch-status.yml
vendored
Normal file
122
.github/workflows/deploy-branch-status.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: 'Deploy: Branch Status Pages'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- 'CI: Tests Storybook'
|
||||
- 'CI: Tests E2E'
|
||||
types:
|
||||
- completed
|
||||
branches-ignore:
|
||||
- main
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-branch-status-${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push' ||
|
||||
(github.event.workflow_run.conclusion == 'success')
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build status pages
|
||||
run: pnpm pages:build
|
||||
|
||||
- name: Install wrangler
|
||||
run: npm install -g wrangler@^4.0.0
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
id: deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.workflow_run.head_branch || github.ref_name }}"
|
||||
CF_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
|
||||
echo "Deploying branch status pages for: $BRANCH (cf: $CF_BRANCH)"
|
||||
|
||||
OUTPUT=$(wrangler pages deploy .pages-dist \
|
||||
--project-name="comfyui-frontend-reports" \
|
||||
--branch="$CF_BRANCH" 2>&1) || {
|
||||
echo "Deployment failed: $OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
URL=$(echo "$OUTPUT" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
|
||||
URL="${URL:-https://${CF_BRANCH}.comfyui-frontend-reports.pages.dev}"
|
||||
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "Deployed to: $URL"
|
||||
|
||||
- name: Find PR number
|
||||
id: pr
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const branch = '${{ steps.deploy.outputs.branch }}';
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
head: `${context.repo.owner}:${branch}`,
|
||||
state: 'open'
|
||||
});
|
||||
return prs.length > 0 ? prs[0].number : null;
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.pr.outputs.result && steps.pr.outputs.result != 'null'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- BRANCH_STATUS_DEPLOY -->';
|
||||
const prNumber = ${{ steps.pr.outputs.result }};
|
||||
const url = '${{ steps.deploy.outputs.url }}';
|
||||
|
||||
const body = `${marker}
|
||||
**Branch Status Page** deployed: ${url}
|
||||
|
||||
Includes: Storybook links, Nx graph, Knip report, test report links`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body
|
||||
});
|
||||
}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -18,7 +18,6 @@ yarn.lock
|
||||
.stylelintcache
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -79,7 +78,7 @@ templates_repo/
|
||||
vite.config.mts.timestamp-*.mjs
|
||||
|
||||
# Linux core dumps
|
||||
/core
|
||||
core
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
@@ -94,7 +93,6 @@ storybook-static
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
# Generated reports in .pages (exclude generated, keep HTML templates)
|
||||
/.pages/*/**/*
|
||||
/.pages-dist/
|
||||
|
||||
54
.pages/README.md
Normal file
54
.pages/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Branch Status Pages
|
||||
|
||||
This directory contains the source for branch status pages deployed to **Cloudflare Pages**.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Push to Branch
|
||||
|
|
||||
+-> GitHub Actions (runs tests, deploys artifacts)
|
||||
| +-> Storybook -> comfy-storybook.pages.dev
|
||||
| +-> E2E Tests -> comfyui-playwright-*.pages.dev
|
||||
| +-> Branch Status -> comfyui-frontend-reports.pages.dev
|
||||
|
|
||||
+-> deploy-branch-status.yml
|
||||
|
|
||||
Runs: pnpm pages:build
|
||||
|
|
||||
Deploys to Cloudflare Pages via wrangler
|
||||
```
|
||||
|
||||
## URLs
|
||||
|
||||
- **Production** (main branch): `https://comfyui-frontend-reports.pages.dev`
|
||||
- **Preview** (PR branches): `https://<branch>.comfyui-frontend-reports.pages.dev`
|
||||
|
||||
## What's Included
|
||||
|
||||
1. **Storybook** - Links to Cloudflare Pages deployment
|
||||
2. **Nx Dependency Graph** - Generated during build
|
||||
3. **Playwright Reports** - Links to Cloudflare Pages deployments
|
||||
4. **Vitest Reports** - Links to CI artifacts
|
||||
5. **Coverage Report** - Links to CI artifacts
|
||||
6. **Knip Report** - Generated fresh during build
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
pnpm pages:dev # Dev server
|
||||
pnpm pages:build # Build
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
Required in GitHub Actions:
|
||||
- `CLOUDFLARE_API_TOKEN`
|
||||
- `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
## Files
|
||||
|
||||
- **index.html** - Landing page with links to all reports
|
||||
- **vite.config.ts** - Vite config for building the static site
|
||||
- **knip.html** - Knip markdown report viewer
|
||||
- **playwright-reports.html** - E2E test report viewer
|
||||
211
.pages/index.html
Normal file
211
.pages/index.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ComfyUI Frontend - Development Tools</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #52b2bb 100%);
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
font-size: 1.125rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
p,h1,h2,h3,h4,h5,h6 {
|
||||
transform: matrix(1, 0, -0.25, 1, 0, 0);
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.card-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
.card-description {
|
||||
color: #718096;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card-status {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-available {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
.status-loading {
|
||||
background: #e9d8fd;
|
||||
color: #44337a;
|
||||
}
|
||||
.status-unavailable {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-top: 3rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.footer a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎨 ComfyUI Frontend</h1>
|
||||
<p>Development Tools & Documentation</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<a href="./storybook/index.html" class="card" data-status="storybook" data-fetch="./storybook/index.html">
|
||||
<div class="card-icon">📚</div>
|
||||
<h2 class="card-title">Storybook</h2>
|
||||
<p class="card-description">Interactive component library and design system documentation</p>
|
||||
<span class="card-status status-loading" data-status-indicator>Checking…</span>
|
||||
</a>
|
||||
|
||||
<a href="./nx-graph/index.html" class="card" data-status="nx-graph" data-fetch="./nx-graph/index.html">
|
||||
<div class="card-icon">🔗</div>
|
||||
<h2 class="card-title">Nx Dependency Graph</h2>
|
||||
<p class="card-description">Visual representation of project dependencies and build structure</p>
|
||||
<span class="card-status status-loading" data-status-indicator>Checking…</span>
|
||||
</a>
|
||||
|
||||
<a href="./coverage/index.html" class="card" data-status="coverage" data-fetch="./coverage/index.html">
|
||||
<div class="card-icon">📊</div>
|
||||
<h2 class="card-title">Test Coverage</h2>
|
||||
<p class="card-description">Code coverage reports from Vitest unit tests</p>
|
||||
<span class="card-status status-loading" data-status-indicator>Checking…</span>
|
||||
</a>
|
||||
|
||||
<a href="./playwright-reports/index.html" class="card" data-status="playwright" data-fetch="./playwright-reports/index.html">
|
||||
<div class="card-icon">🎭</div>
|
||||
<h2 class="card-title">Playwright E2E</h2>
|
||||
<p class="card-description">Browser end-to-end test reports generated by Playwright</p>
|
||||
<span class="card-status status-loading" data-status-indicator>Checking…</span>
|
||||
</a>
|
||||
|
||||
<a href="./vitest-reports/index.html" class="card" data-status="vitest-reports" data-fetch="./vitest-reports/index.html">
|
||||
<div class="card-icon">🧪</div>
|
||||
<h2 class="card-title">Vitest Results</h2>
|
||||
<p class="card-description">Interactive test results and reports</p>
|
||||
<span class="card-status status-loading" data-status-indicator>Checking…</span>
|
||||
</a>
|
||||
|
||||
<a href="./knip.html" class="card" data-status="knip" data-fetch="./knip/report.md">
|
||||
<div class="card-icon">🔍</div>
|
||||
<h2 class="card-title">Knip Report</h2>
|
||||
<p class="card-description">Unused code and dependency analysis</p>
|
||||
<span class="card-status status-loading" data-status-indicator>Checking…</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
Built from the <strong>main</strong> branch •
|
||||
<a href="https://github.com/Comfy-Org/ComfyUI_frontend" target="_blank">GitHub Repository</a> •
|
||||
<a href="https://docs.comfy.org" target="_blank">Official Documentation</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const cards = Array.from(document.querySelectorAll('.card[data-status]'))
|
||||
|
||||
const setStatus = (card, variant, text) => {
|
||||
const indicator = card.querySelector('[data-status-indicator]')
|
||||
if (!indicator) return
|
||||
indicator.classList.remove('status-loading', 'status-available', 'status-unavailable')
|
||||
indicator.classList.add(`status-${variant}`)
|
||||
indicator.textContent = text
|
||||
}
|
||||
|
||||
const absoluteUrl = (href) => new URL(href, window.location.href).toString()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
cards.forEach((card) => {
|
||||
const href = card.dataset.fetch || card.getAttribute('href')
|
||||
if (!href) {
|
||||
setStatus(card, 'unavailable', 'No link configured')
|
||||
return
|
||||
}
|
||||
|
||||
fetch(absoluteUrl(href), { cache: 'no-store', signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
setStatus(card, 'available', 'Available')
|
||||
} else {
|
||||
setStatus(card, 'unavailable', `Unavailable (${response.status})`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const reason = error.name === 'AbortError' ? 'Timed out' : 'Unavailable'
|
||||
setStatus(card, 'unavailable', reason)
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearTimeout(timeout)
|
||||
controller.abort()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
87
.pages/knip.html
Normal file
87
.pages/knip.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Knip Report</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
padding: 2rem;
|
||||
background: #1a1a1a;
|
||||
color: #f0f0f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 {
|
||||
color: #4a9eff;
|
||||
border-bottom: 3px solid #4a9eff;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.error {
|
||||
background: #d32f2f;
|
||||
color: #ffebee;
|
||||
}
|
||||
.loading {
|
||||
background: #f57c00;
|
||||
color: #fff3e0;
|
||||
}
|
||||
#content {
|
||||
background: #2a2a2a;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧹 Knip Code Quality Report</h1>
|
||||
|
||||
<div id="status" class="status loading">Loading report...</div>
|
||||
|
||||
<div id="content"></div>
|
||||
|
||||
<script>
|
||||
async function loadReport() {
|
||||
const statusEl = document.getElementById('status')
|
||||
const contentEl = document.getElementById('content')
|
||||
|
||||
try {
|
||||
const response = await fetch('./knip/report.md')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const reportText = await response.text()
|
||||
statusEl.style.display = 'none'
|
||||
|
||||
// Wait for marked to be available
|
||||
if (typeof marked !== 'undefined') {
|
||||
contentEl.innerHTML = marked.parse(reportText)
|
||||
} else {
|
||||
contentEl.innerHTML = `<pre>${reportText}</pre>`
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.className = 'status error'
|
||||
statusEl.textContent = `Failed to load report: ${error.message}`
|
||||
|
||||
contentEl.innerHTML = '<p>The Knip report could not be loaded. This might happen if:</p><ul><li>The report generation failed during build</li><li>No unused code was detected</li><li>Network connectivity issues</li></ul>'
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for marked library to load before running
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadReport)
|
||||
} else {
|
||||
loadReport()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
300
.pages/playwright-reports.html
Normal file
300
.pages/playwright-reports.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playwright E2E Test Reports</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
font-size: 2.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 1rem auto;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #d32f2f;
|
||||
color: #ffebee;
|
||||
}
|
||||
|
||||
.loading {
|
||||
background: #f57c00;
|
||||
color: #fff3e0;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.pass-rate {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.pass-rate.has-failures {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat.passed .value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat.failed .value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat.skipped .value {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat.flaky .value {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎭 Playwright E2E Test Reports</h1>
|
||||
|
||||
<div id="status" class="status loading">Loading reports...</div>
|
||||
|
||||
<div id="content" class="cards-grid"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function getTestStats(browserName) {
|
||||
try {
|
||||
const reportJsonPath = `./playwright-reports/${browserName}/report.json`
|
||||
const response = await fetch(reportJsonPath)
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`No report.json found for ${browserName}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const reportData = await response.json()
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let flaky = 0
|
||||
|
||||
// Parse Playwright JSON report format
|
||||
if (reportData.suites) {
|
||||
const countResults = (suites) => {
|
||||
for (const suite of suites) {
|
||||
if (suite.specs) {
|
||||
for (const spec of suite.specs) {
|
||||
if (!spec.tests || spec.tests.length === 0) continue
|
||||
|
||||
const test = spec.tests[0]
|
||||
const results = test.results || []
|
||||
|
||||
// Check if test is flaky (has both pass and fail results)
|
||||
const hasPass = results.some((r) => r.status === 'passed')
|
||||
const hasFail = results.some((r) => r.status === 'failed')
|
||||
|
||||
if (hasPass && hasFail) {
|
||||
flaky++
|
||||
} else if (results.some((r) => r.status === 'passed')) {
|
||||
passed++
|
||||
} else if (results.some((r) => r.status === 'failed')) {
|
||||
failed++
|
||||
} else if (results.some((r) => r.status === 'skipped')) {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
if (suite.suites) {
|
||||
countResults(suite.suites)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countResults(reportData.suites)
|
||||
}
|
||||
|
||||
return { passed, failed, skipped, flaky }
|
||||
} catch (error) {
|
||||
console.error(`Error reading report for ${browserName}:`, error.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createCard(browserName, stats) {
|
||||
if (!stats) return ''
|
||||
|
||||
const total = stats.passed + stats.failed + stats.skipped + stats.flaky
|
||||
const passRate = total > 0 ? ((stats.passed / total) * 100).toFixed(1) : 0
|
||||
|
||||
return `
|
||||
<a href="./playwright-reports/${browserName}/index.html" class="card">
|
||||
<div class="card-header">
|
||||
<h2>${browserName}</h2>
|
||||
<span class="pass-rate ${stats.failed > 0 ? 'has-failures' : ''}">${passRate}%</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat passed">
|
||||
<span class="label">Passed</span>
|
||||
<span class="value">${stats.passed}</span>
|
||||
</div>
|
||||
<div class="stat failed">
|
||||
<span class="label">Failed</span>
|
||||
<span class="value">${stats.failed}</span>
|
||||
</div>
|
||||
<div class="stat skipped">
|
||||
<span class="label">Skipped</span>
|
||||
<span class="value">${stats.skipped}</span>
|
||||
</div>
|
||||
<div class="stat flaky">
|
||||
<span class="label">Flaky</span>
|
||||
<span class="value">${stats.flaky}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const statusEl = document.getElementById('status')
|
||||
const contentEl = document.getElementById('content')
|
||||
|
||||
try {
|
||||
// Known browser configurations from the workflow
|
||||
const browsers = ['chromium', 'chromium-2x', 'chromium-0.5x', 'mobile-chrome']
|
||||
|
||||
const cards = []
|
||||
|
||||
for (const browser of browsers) {
|
||||
const stats = await getTestStats(browser)
|
||||
if (stats) {
|
||||
cards.push(createCard(browser, stats))
|
||||
console.log(`✓ Found report for ${browser}:`, stats)
|
||||
}
|
||||
}
|
||||
|
||||
if (cards.length === 0) {
|
||||
throw new Error('No valid browser reports found')
|
||||
}
|
||||
|
||||
statusEl.style.display = 'none'
|
||||
contentEl.innerHTML = cards.join('')
|
||||
} catch (error) {
|
||||
statusEl.className = 'status error'
|
||||
statusEl.textContent = `Failed to load reports: ${error.message}`
|
||||
|
||||
contentEl.innerHTML = '<p style="color: white; text-align: center;">The Playwright reports could not be loaded. This might happen if:</p><ul style="color: white; text-align: center; list-style: none;"><li>The report generation failed during build</li><li>No test reports are available yet</li><li>Network connectivity issues</li></ul>'
|
||||
}
|
||||
}
|
||||
|
||||
// Load reports when page loads
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadReports)
|
||||
} else {
|
||||
loadReports()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
51
.pages/vite.config.ts
Normal file
51
.pages/vite.config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const rootDir = __dirname
|
||||
const outDir = resolve(rootDir, '../.pages-dist')
|
||||
|
||||
const discoverHtmlEntries = () => {
|
||||
const entries = new Map<string, string>()
|
||||
const topLevel = resolve(rootDir, 'index.html')
|
||||
if (fs.existsSync(topLevel)) entries.set('index', topLevel)
|
||||
|
||||
// Directories with pre-built assets (e.g. nx-graph) are copied directly
|
||||
// in build-pages.sh to avoid CSS minification issues
|
||||
const skipDirs = new Set(['nx-graph'])
|
||||
|
||||
for (const dirent of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue
|
||||
if (skipDirs.has(dirent.name)) continue
|
||||
const candidate = resolve(rootDir, dirent.name, 'index.html')
|
||||
if (fs.existsSync(candidate)) entries.set(dirent.name, candidate)
|
||||
}
|
||||
|
||||
return entries.size > 0 ? Object.fromEntries(entries) : undefined
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: rootDir,
|
||||
base: '/',
|
||||
appType: 'mpa',
|
||||
logLevel: 'info',
|
||||
publicDir: false,
|
||||
server: {
|
||||
open: '/index.html',
|
||||
fs: {
|
||||
allow: [rootDir],
|
||||
strict: false
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
open: '/index.html'
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir,
|
||||
copyPublicDir: false,
|
||||
rollupOptions: {
|
||||
input: discoverHtmlEntries()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -100,8 +100,7 @@ const config: StorybookConfig = {
|
||||
rolldownOptions: {
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true,
|
||||
strictExecutionOrder: true
|
||||
keepNames: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
|
||||
@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param value - The value to set
|
||||
*/
|
||||
async setStringSetting(id: string, value: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
await settingInputDiv.locator('input').fill(value)
|
||||
}
|
||||
|
||||
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param id - The id of the setting
|
||||
*/
|
||||
async toggleBooleanSetting(id: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
get searchBox() {
|
||||
return this.root.getByPlaceholder(/Search/)
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.root.locator('nav').getByRole('button')
|
||||
}
|
||||
|
||||
category(name: string) {
|
||||
return this.root.locator('nav').getByRole('button', { name })
|
||||
}
|
||||
|
||||
get contentArea() {
|
||||
return this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
|
||||
await this.page
|
||||
.getByTestId(TestIds.dialogs.about)
|
||||
.waitFor({ state: 'visible' })
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
})
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('.about-container')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
|
||||
test.describe('Settings', () => {
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsContent = comfyPage.page.locator('.settings-content')
|
||||
await expect(settingsContent).toBeVisible()
|
||||
const isUsableHeight = await settingsContent.evaluate(
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const contentArea = settingsDialog.locator('main')
|
||||
await expect(contentArea).toBeVisible()
|
||||
const isUsableHeight = await contentArea.evaluate(
|
||||
(el) => el.clientHeight > 30
|
||||
)
|
||||
expect(isUsableHeight).toBeTruthy()
|
||||
@@ -256,7 +260,9 @@ test.describe('Settings', () => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
const settingsLocator = comfyPage.page.locator('.settings-container')
|
||||
const settingsLocator = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsLocator).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsLocator).not.toBeVisible()
|
||||
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
|
||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||
// Open the settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
||||
|
||||
// Open the keybinding tab
|
||||
await comfyPage.page.getByLabel('Keybinding').click()
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await settingsDialog
|
||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||
.click()
|
||||
await comfyPage.page.waitForSelector(
|
||||
'[placeholder="Search Keybindings..."]'
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -826,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container', {
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
@@ -836,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('.settings-container')
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 115 KiB |
@@ -22,7 +22,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
name: 'TestSettingsExtension',
|
||||
settings: [
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestHiddenSetting' as TestSettingId,
|
||||
name: 'Test Hidden Setting',
|
||||
type: 'hidden',
|
||||
@@ -30,7 +29,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
category: ['Test', 'Hidden']
|
||||
},
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestDeprecatedSetting' as TestSettingId,
|
||||
name: 'Test Deprecated Setting',
|
||||
type: 'text',
|
||||
@@ -39,7 +37,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
category: ['Test', 'Deprecated']
|
||||
},
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestVisibleSetting' as TestSettingId,
|
||||
name: 'Test Visible Setting',
|
||||
type: 'text',
|
||||
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
})
|
||||
|
||||
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await expect(searchBox).toBeVisible()
|
||||
|
||||
// Verify search box has the correct placeholder
|
||||
await expect(searchBox).toHaveAttribute(
|
||||
await expect(dialog.searchBox).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search')
|
||||
)
|
||||
})
|
||||
|
||||
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Comfy')
|
||||
|
||||
// Verify the input was accepted
|
||||
await expect(searchBox).toHaveValue('Comfy')
|
||||
await dialog.searchBox.fill('Comfy')
|
||||
await expect(dialog.searchBox).toHaveValue('Comfy')
|
||||
})
|
||||
|
||||
test('search box clears properly', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('test')
|
||||
await expect(searchBox).toHaveValue('test')
|
||||
await dialog.searchBox.fill('test')
|
||||
await expect(dialog.searchBox).toHaveValue('test')
|
||||
|
||||
// Clear the search box
|
||||
await searchBox.clear()
|
||||
await expect(searchBox).toHaveValue('')
|
||||
await dialog.searchBox.clear()
|
||||
await expect(dialog.searchBox).toHaveValue('')
|
||||
})
|
||||
|
||||
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Check that the sidebar has categories
|
||||
const categories = comfyPage.page.locator(
|
||||
'.settings-sidebar .p-listbox-option'
|
||||
)
|
||||
expect(await categories.count()).toBeGreaterThan(0)
|
||||
|
||||
// Check that at least one category is visible
|
||||
await expect(categories.first()).toBeVisible()
|
||||
expect(await dialog.categories.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Click on a specific category (Appearance) to verify category switching
|
||||
const appearanceCategory = comfyPage.page.getByRole('option', {
|
||||
name: 'Appearance'
|
||||
})
|
||||
await appearanceCategory.click()
|
||||
const categoryCount = await dialog.categories.count()
|
||||
|
||||
// Verify the category is selected
|
||||
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
|
||||
})
|
||||
if (categoryCount > 1) {
|
||||
await dialog.categories.nth(1).click()
|
||||
|
||||
test('settings content area is visible', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Check that the content area is visible
|
||||
const contentArea = comfyPage.page.locator('.settings-content')
|
||||
await expect(contentArea).toBeVisible()
|
||||
|
||||
// Check that tab panels are visible
|
||||
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
|
||||
await expect(tabPanels).toBeVisible()
|
||||
await expect(dialog.categories.nth(1)).toHaveClass(
|
||||
/bg-interface-menu-component-surface-selected/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('search functionality affects UI state', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
|
||||
// Type in search box
|
||||
await searchBox.fill('graph')
|
||||
|
||||
// Verify that the search input is handled
|
||||
await expect(searchBox).toHaveValue('graph')
|
||||
await dialog.searchBox.fill('graph')
|
||||
await expect(dialog.searchBox).toHaveValue('graph')
|
||||
})
|
||||
|
||||
test('settings dialog can be closed', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Close with escape key
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(settingsDialog).not.toBeVisible()
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Type rapidly in search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('a')
|
||||
await searchBox.fill('ab')
|
||||
await searchBox.fill('abc')
|
||||
await searchBox.fill('abcd')
|
||||
await dialog.searchBox.fill('a')
|
||||
await dialog.searchBox.fill('ab')
|
||||
await dialog.searchBox.fill('abc')
|
||||
await dialog.searchBox.fill('abcd')
|
||||
|
||||
// Verify final value
|
||||
await expect(searchBox).toHaveValue('abcd')
|
||||
await expect(dialog.searchBox).toHaveValue('abcd')
|
||||
})
|
||||
|
||||
test('search excludes hidden settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not hidden setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
})
|
||||
|
||||
test('search excludes deprecated settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not deprecated setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
})
|
||||
|
||||
test('search shows visible settings but excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should only show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
|
||||
// Should not show hidden or deprecated settings
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
})
|
||||
|
||||
test('search by setting name excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Hidden')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for hidden setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Hidden')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Deprecated')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
|
||||
// Should not show the hidden setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for deprecated setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Deprecated')
|
||||
|
||||
// Should not show the deprecated setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
|
||||
// Search for visible setting by name - should work
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Visible')
|
||||
|
||||
// Should show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Visible')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,15 +8,10 @@ const config: KnipConfig = {
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
'src/types/index.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
'apps/desktop-ui': {
|
||||
entry: ['src/main.ts', 'src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
@@ -27,19 +22,49 @@ const config: KnipConfig = {
|
||||
ignoreBinaries: ['python3', 'gh'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
'@primevue/icons',
|
||||
// Unused but kept for potential future use
|
||||
'@sparkjsdev/spark',
|
||||
'wwobjloader2',
|
||||
'@iconify-json/lucide',
|
||||
'yjs'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
'.pages/vite.config.ts',
|
||||
// Utility files with exports that may be used by extensions or future features
|
||||
'src/constants/uvMirrors.ts',
|
||||
'src/lib/litegraph/src/measure.ts',
|
||||
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts',
|
||||
'src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts',
|
||||
'src/utils/electronMirrorCheck.ts',
|
||||
'src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts',
|
||||
'src/types/spatialIndex.ts',
|
||||
'src/lib/litegraph/src/litegraph.ts',
|
||||
'src/utils/vintageClipboard.ts',
|
||||
'src/platform/auth/session/useSessionCookie.ts',
|
||||
'src/platform/support/config.ts',
|
||||
'src/renderer/utils/nodeTypeGuards.ts',
|
||||
'src/schemas/nodeDefSchema.ts',
|
||||
'src/scripts/defaultGraph.ts',
|
||||
'src/scripts/ui.ts',
|
||||
'src/scripts/widgets.ts',
|
||||
'src/services/litegraphService.ts',
|
||||
'src/storybook/mocks/useJobActions.ts',
|
||||
'src/storybook/mocks/useJobList.ts',
|
||||
'src/utils/executableGroupNodeDto.ts',
|
||||
'src/utils/litegraphUtil.ts',
|
||||
'src/lib/litegraph/src/LGraph.ts',
|
||||
'src/schemas/apiSchema.ts',
|
||||
'src/schemas/nodeDef/nodeDefSchemaV2.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
"pages:build": "bash scripts/build-pages.sh && vite build --config ./.pages/vite.config.ts && cp -r .pages/nx-graph .pages-dist/nx-graph 2>/dev/null || true",
|
||||
"pages:dev": "vite --config ./.pages/vite.config.ts",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
|
||||
152
scripts/build-pages.sh
Executable file
152
scripts/build-pages.sh
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Building Branch Status Pages"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
|
||||
# Helper function to create placeholder HTML
|
||||
create_placeholder() {
|
||||
local dir="$1"
|
||||
local title="$2"
|
||||
local message="$3"
|
||||
|
||||
mkdir -p "$dir"
|
||||
cat > "$dir/index.html" <<EOF
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$title</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
backdrop-filter: blur(10px);
|
||||
max-width: 600px;
|
||||
}
|
||||
h1 { margin-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>$title</h1>
|
||||
<p>$message</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Storybook (deployed separately to Cloudflare Pages)
|
||||
# ============================================================================
|
||||
echo "[build-pages] Setting up Storybook"
|
||||
rm -rf ".pages/storybook"
|
||||
|
||||
if [ -d "./storybook-static" ] && [ "$(find ./storybook-static -name '*.html' 2>/dev/null | wc -l)" -gt 0 ]; then
|
||||
echo " Using local Storybook build"
|
||||
cp -r "./storybook-static" ".pages/storybook"
|
||||
else
|
||||
echo " Creating placeholder (Storybook deployed separately to comfy-storybook.pages.dev)"
|
||||
create_placeholder ".pages/storybook" "Storybook" \
|
||||
"Storybook is deployed separately. Check the PR comments for the Cloudflare Pages link."
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Nx Dependency Graph
|
||||
# ============================================================================
|
||||
echo "[build-pages] Generating Nx dependency graph"
|
||||
rm -rf ".pages/nx-graph" && mkdir -p ".pages/nx-graph"
|
||||
|
||||
if pnpm nx graph --file=".pages/nx-graph/index.html" 2>/dev/null; then
|
||||
echo " Nx graph generated"
|
||||
else
|
||||
echo " Nx graph generation failed, creating placeholder"
|
||||
create_placeholder ".pages/nx-graph" "Nx Dependency Graph" \
|
||||
"Graph generation is not available in this environment."
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Playwright E2E Test Reports (deployed separately to Cloudflare Pages)
|
||||
# ============================================================================
|
||||
echo "[build-pages] Setting up Playwright test reports"
|
||||
rm -rf ".pages/playwright-reports" && mkdir -p ".pages/playwright-reports"
|
||||
|
||||
if [ -d "./playwright-report" ] && [ "$(find ./playwright-report -name '*.html' 2>/dev/null | wc -l)" -gt 0 ]; then
|
||||
echo " Using local Playwright reports"
|
||||
cp -r "./playwright-report/"* ".pages/playwright-reports/" 2>/dev/null || true
|
||||
else
|
||||
echo " Creating placeholder (Playwright reports deployed separately)"
|
||||
create_placeholder ".pages/playwright-reports" "E2E Test Reports" \
|
||||
"Playwright reports are deployed separately. Check the PR comments for Cloudflare Pages links."
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Vitest Test Reports
|
||||
# ============================================================================
|
||||
echo "[build-pages] Setting up Vitest test reports"
|
||||
rm -rf ".pages/vitest-reports" && mkdir -p ".pages/vitest-reports"
|
||||
|
||||
create_placeholder ".pages/vitest-reports" "Vitest Test Reports" \
|
||||
"Unit test results are available in CI. Check the GitHub Actions workflow run."
|
||||
|
||||
# ============================================================================
|
||||
# Coverage Report
|
||||
# ============================================================================
|
||||
echo "[build-pages] Setting up coverage report"
|
||||
mkdir -p ".pages/coverage"
|
||||
|
||||
create_placeholder ".pages/coverage" "Coverage Report" \
|
||||
"Code coverage is generated in CI. Check the GitHub Actions workflow run."
|
||||
|
||||
# ============================================================================
|
||||
# Knip Report (Fast - generate fresh)
|
||||
# ============================================================================
|
||||
echo "[build-pages] Generating Knip report"
|
||||
mkdir -p ".pages/knip"
|
||||
rm -f ".pages/knip/report.md"
|
||||
|
||||
if pnpm knip --reporter markdown --no-progress --no-exit-code > ".pages/knip/report.md" 2>/dev/null && [ -s ".pages/knip/report.md" ]; then
|
||||
echo " Knip report generated"
|
||||
else
|
||||
echo " Knip report failed, creating placeholder"
|
||||
cat > ".pages/knip/report.md" <<'EOF'
|
||||
# Knip Report
|
||||
|
||||
> Knip report generation failed
|
||||
|
||||
Knip analysis could not be completed in this build environment.
|
||||
Please check the CI logs for details.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Summary
|
||||
# ============================================================================
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo "Build Complete"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
echo "Generated artifacts in ./.pages:"
|
||||
echo ""
|
||||
ls -lh ".pages" 2>/dev/null | tail -n +2 | awk '{print " " $9}'
|
||||
echo ""
|
||||
290
scripts/create-playwright-index.js
Executable file
290
scripts/create-playwright-index.js
Executable file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Creates an index page for Playwright test reports with test statistics
|
||||
* Reads JSON reports from each browser and creates a landing page with cards
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const reportsDir = path.join(__dirname, '..', '.pages', 'playwright-reports')
|
||||
|
||||
function getTestStats(reportPath) {
|
||||
try {
|
||||
const reportJsonPath = path.join(reportPath, 'report.json')
|
||||
if (!fs.existsSync(reportJsonPath)) {
|
||||
console.warn(`No report.json found at ${reportJsonPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const reportData = JSON.parse(fs.readFileSync(reportJsonPath, 'utf-8'))
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let flaky = 0
|
||||
|
||||
// Parse Playwright JSON report format
|
||||
if (reportData.suites) {
|
||||
const countResults = (suites) => {
|
||||
for (const suite of suites) {
|
||||
if (suite.specs) {
|
||||
for (const spec of suite.specs) {
|
||||
if (!spec.tests || spec.tests.length === 0) continue
|
||||
|
||||
const test = spec.tests[0]
|
||||
const results = test.results || []
|
||||
|
||||
// Check if test is flaky (has both pass and fail results)
|
||||
const hasPass = results.some((r) => r.status === 'passed')
|
||||
const hasFail = results.some((r) => r.status === 'failed')
|
||||
|
||||
if (hasPass && hasFail) {
|
||||
flaky++
|
||||
} else if (results.some((r) => r.status === 'passed')) {
|
||||
passed++
|
||||
} else if (results.some((r) => r.status === 'failed')) {
|
||||
failed++
|
||||
} else if (results.some((r) => r.status === 'skipped')) {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
if (suite.suites) {
|
||||
countResults(suite.suites)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countResults(reportData.suites)
|
||||
}
|
||||
|
||||
return { passed, failed, skipped, flaky }
|
||||
} catch (error) {
|
||||
console.error(`Error reading report at ${reportPath}:`, error.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function generateIndexHtml(browsers) {
|
||||
const cards = browsers
|
||||
.map((browser) => {
|
||||
const { name, stats } = browser
|
||||
if (!stats) return ''
|
||||
|
||||
const total = stats.passed + stats.failed + stats.skipped + stats.flaky
|
||||
const passRate = total > 0 ? ((stats.passed / total) * 100).toFixed(1) : 0
|
||||
|
||||
return `
|
||||
<a href="./${name}/index.html" class="card">
|
||||
<div class="card-header">
|
||||
<h2>${name}</h2>
|
||||
<span class="pass-rate ${stats.failed > 0 ? 'has-failures' : ''}">${passRate}%</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat passed">
|
||||
<span class="label">Passed</span>
|
||||
<span class="value">${stats.passed}</span>
|
||||
</div>
|
||||
<div class="stat failed">
|
||||
<span class="label">Failed</span>
|
||||
<span class="value">${stats.failed}</span>
|
||||
</div>
|
||||
<div class="stat skipped">
|
||||
<span class="label">Skipped</span>
|
||||
<span class="value">${stats.skipped}</span>
|
||||
</div>
|
||||
<div class="stat flaky">
|
||||
<span class="label">Flaky</span>
|
||||
<span class="value">${stats.flaky}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playwright E2E Test Reports</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
font-size: 2.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.pass-rate {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.pass-rate.has-failures {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat.passed .value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat.failed .value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat.skipped .value {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat.flaky .value {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎭 Playwright E2E Test Reports</h1>
|
||||
<div class="cards-grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(reportsDir)) {
|
||||
console.log(
|
||||
'No playwright reports directory found, skipping index creation'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const browsers = []
|
||||
const browserDirs = fs.readdirSync(reportsDir, { withFileTypes: true })
|
||||
|
||||
for (const dirent of browserDirs) {
|
||||
if (dirent.isDirectory()) {
|
||||
const browserName = dirent.name
|
||||
const browserPath = path.join(reportsDir, browserName)
|
||||
const stats = getTestStats(browserPath)
|
||||
|
||||
if (stats) {
|
||||
browsers.push({ name: browserName, stats })
|
||||
console.log(`✓ Found report for ${browserName}:`, stats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (browsers.length === 0) {
|
||||
console.warn('No valid browser reports found')
|
||||
return
|
||||
}
|
||||
|
||||
const html = generateIndexHtml(browsers)
|
||||
const indexPath = path.join(reportsDir, 'index.html')
|
||||
|
||||
fs.writeFileSync(indexPath, html, 'utf-8')
|
||||
console.log(`✓ Created index page at ${indexPath}`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
settingsDialog.show('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
@@ -854,19 +854,3 @@ onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Ensure the workflow template selector dialog fits within provided dialog */
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
width: 100% !important;
|
||||
max-width: 1400px;
|
||||
height: 100% !important;
|
||||
aspect-ratio: auto !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
|
||||
@@ -116,7 +116,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -134,10 +134,7 @@ const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
function openBlueprintOverwriteSetting() {
|
||||
useDialogStore().closeDialog()
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
|
||||
}
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
@@ -105,10 +105,7 @@ const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
|
||||
@@ -80,7 +80,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -103,10 +103,7 @@ const handleGotItClick = () => {
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingNodesWarning'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
settingsDialog.show(settingsPanel)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
})
|
||||
await fetchBalance()
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
settingsDialog.show('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<PanelTemplate
|
||||
value="About"
|
||||
class="about-container"
|
||||
data-testid="about-panel"
|
||||
>
|
||||
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('g.about') }}
|
||||
</h2>
|
||||
@@ -32,7 +28,7 @@
|
||||
v-if="systemStatsStore.systemStats"
|
||||
:stats="systemStatsStore.systemStats"
|
||||
/>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -43,8 +39,6 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<template>
|
||||
<PanelTemplate value="Keybinding" class="keybinding-panel">
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
@@ -135,7 +131,7 @@
|
||||
<i class="pi pi-replay" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -159,7 +155,6 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
const filters = ref({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
@@ -102,7 +102,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -110,7 +110,6 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length
|
||||
count:
|
||||
isSingleSeatPlan || isPersonalWorkspace
|
||||
? 1
|
||||
: members.length,
|
||||
maxSeats: maxSeats
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
@@ -27,7 +28,10 @@
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<div
|
||||
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
@@ -45,14 +49,16 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
isSingleSeatPlan
|
||||
? 'grid-cols-1 py-0'
|
||||
: activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
@@ -101,17 +107,19 @@
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
<template v-if="!isSingleSeatPlan">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +174,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols,
|
||||
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
@@ -206,14 +214,14 @@
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn"
|
||||
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
|
||||
class="text-sm text-muted-foreground text-right"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
</span>
|
||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers"
|
||||
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
@@ -237,8 +245,29 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Upsell Banner -->
|
||||
<div
|
||||
v-if="isSingleSeatPlan"
|
||||
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
|
||||
>
|
||||
<p class="m-0 text-sm text-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.members.upsellBannerUpgrade')
|
||||
: $t('workspacePanel.members.upsellBannerSubscribe')
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="cursor-pointer underline text-sm"
|
||||
@click="showSubscriptionDialog()"
|
||||
>
|
||||
{{ $t('workspacePanel.members.viewPlans') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<template v-else>
|
||||
<template v-if="activeView === 'pending'">
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
PendingInvite,
|
||||
@@ -367,6 +398,27 @@ const {
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const maxSeats = computed(() => {
|
||||
if (isPersonalWorkspace.value) return 1
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return 1
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
if (!tierKey) return 1
|
||||
return getMaxSeats(tierKey)
|
||||
})
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (isPersonalWorkspace.value) return false
|
||||
if (!isActiveSubscription.value) return true
|
||||
return maxSeats.value <= 1
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<'active' | 'pending'>('active')
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
||||
<div class="flex h-full w-full flex-col gap-2">
|
||||
<slot name="header" />
|
||||
<ScrollPanel class="h-0 grow pr-2">
|
||||
<slot />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabPanel value="User" class="user-settings-container h-full">
|
||||
<div class="user-settings-container h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
||||
<Divider class="mb-3" />
|
||||
@@ -95,13 +95,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<header class="mb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
@@ -8,44 +8,38 @@
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
@@ -55,20 +49,22 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:disabled="!isSingleSeatPlan && isInviteLimitReached"
|
||||
:class="
|
||||
!isSingleSeatPlan &&
|
||||
isInviteLimitReached &&
|
||||
'opacity-50 cursor-not-allowed'
|
||||
"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
<i class="pi pi-plus text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
@@ -76,17 +72,21 @@
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
<button
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
||||
]"
|
||||
type="button"
|
||||
:disabled="!!item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 bg-transparent border-none cursor-pointer',
|
||||
item.class,
|
||||
item.disabled && 'pointer-events-auto cursor-not-allowed'
|
||||
)
|
||||
"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
@@ -96,44 +96,47 @@
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const tabTriggerBase =
|
||||
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||
const tabTriggerActive =
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||
const tabTriggerInactive =
|
||||
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
@@ -144,19 +147,26 @@ const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (!isActiveSubscription.value) return true
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return true
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
if (!tierKey) return true
|
||||
return getMaxSeats(tierKey) <= 1
|
||||
})
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||
storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
useWorkspaceUI()
|
||||
|
||||
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
@@ -187,11 +197,16 @@ const deleteTooltip = computed(() => {
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (isSingleSeatPlan.value) return null
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isSingleSeatPlan.value) {
|
||||
showInviteMemberUpsellDialog()
|
||||
return
|
||||
}
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
@@ -231,7 +246,6 @@ const menuItems = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onDismiss"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onDismiss">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onUpgrade">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
|
||||
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
|
||||
function onDismiss() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
}
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
||||
<i class="pi pi-cog" />
|
||||
<span>{{ $t('g.settings') }}</span>
|
||||
<Tag
|
||||
v-if="isStaging"
|
||||
value="staging"
|
||||
severity="warn"
|
||||
class="ml-2 text-xs"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
import { isStaging } from '@/config/staging'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { flags } = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-cog {
|
||||
font-size: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.version-tag {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@ import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
@@ -20,7 +20,7 @@ import LayoutField from './LayoutField.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
|
||||
// NODES settings
|
||||
const showAdvancedParameters = computed({
|
||||
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
|
||||
}
|
||||
|
||||
function openFullSettings() {
|
||||
dialogService.showSettingsDialog()
|
||||
settingsDialog.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -108,15 +108,14 @@ import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingPanelType } from '@/platform/settings/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -129,7 +128,7 @@ const commandStore = useCommandStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const dialogStore = useDialogStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const managerState = useManagerState()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -166,15 +165,8 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
}
|
||||
}
|
||||
|
||||
const showSettings = (defaultPanel?: string) => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
props: {
|
||||
defaultPanel
|
||||
}
|
||||
})
|
||||
const showSettings = (defaultPanel?: SettingPanelType) => {
|
||||
settingsDialog.show(defaultPanel)
|
||||
}
|
||||
|
||||
const showManageExtensions = async () => {
|
||||
|
||||
42
src/components/toast/InviteAcceptedToast.vue
Normal file
42
src/components/toast/InviteAcceptedToast.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Toast group="invite-accepted" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2 justify-between w-full">
|
||||
<div class="flex flex-col justify-start">
|
||||
<div class="text-base">
|
||||
{{ slotProps.message.summary }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-foreground">
|
||||
{{ slotProps.message.detail.text }} <br />
|
||||
{{ slotProps.message.detail.workspaceName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
variant="inverted"
|
||||
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
|
||||
>
|
||||
{{ t('workspace.viewWorkspace') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
|
||||
function viewWorkspace(workspaceId: string) {
|
||||
void switchWithConfirmation(workspaceId)
|
||||
toast.removeGroup('invite-accepted')
|
||||
}
|
||||
</script>
|
||||
@@ -31,6 +31,15 @@ vi.mock('pinia')
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock the settings dialog composable
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettingsDialog,
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
@@ -64,7 +73,6 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSettingsDialog: mockShowSettingsDialog,
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -152,6 +152,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
@@ -165,6 +166,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
@@ -198,7 +200,7 @@ const canUpgrade = computed(() => {
|
||||
})
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
settingsDialog.show('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -209,9 +211,9 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('subscription')
|
||||
settingsDialog.show('subscription')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
settingsDialog.show('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
|
||||
@@ -220,6 +220,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
@@ -239,6 +240,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||
useBillingContext()
|
||||
@@ -284,12 +286,12 @@ const showSubscribeAction = computed(
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
settingsDialog.show('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
settingsDialog.show('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -300,9 +302,9 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
settingsDialog.show('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
settingsDialog.show('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
|
||||
@@ -36,7 +36,7 @@ defineProps<{
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
>
|
||||
<slot>
|
||||
<div class="flex flex-col p-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
||||
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
|
||||
@keydown.esc.capture="handleEscape"
|
||||
>
|
||||
<div
|
||||
@@ -141,14 +141,31 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { contentTitle, rightPanelTitle } = defineProps<{
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'h-[80vh] w-[90vw] max-w-[960px]',
|
||||
md: 'h-[80vh] w-[90vw] max-w-[1400px]',
|
||||
lg: 'h-[80vh] w-[90vw] max-w-[1280px] aspect-[20/13] min-[1450px]:max-w-[1724px]',
|
||||
full: 'h-full w-full max-w-[1400px] 2xl:max-w-[1600px]'
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
|
||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||
default: false
|
||||
})
|
||||
@@ -215,17 +232,3 @@ function handleEscape(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.base-widget-layout {
|
||||
height: 80vh;
|
||||
width: 90vw;
|
||||
max-width: 1280px;
|
||||
aspect-ratio: 20/13;
|
||||
}
|
||||
|
||||
@media (min-width: 1450px) {
|
||||
.base-widget-layout {
|
||||
max-width: 1724px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
@@ -73,4 +74,5 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
const isInPersonalWorkspace = { value: true }
|
||||
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
|
||||
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||
() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: isInPersonalWorkspace.value,
|
||||
activeWorkspace: activeWorkspace.value,
|
||||
_setPersonalWorkspace: (value: boolean) => {
|
||||
isInPersonalWorkspace.value = value
|
||||
activeWorkspace.value = value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
}
|
||||
})
|
||||
...(original as Record<string, unknown>),
|
||||
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get isInPersonalWorkspace() {
|
||||
return mockIsPersonal.value
|
||||
},
|
||||
get activeWorkspace() {
|
||||
return mockIsPersonal.value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
@@ -52,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
|
||||
const plans = { value: [] }
|
||||
const currentPlanSlug = { value: null }
|
||||
return {
|
||||
useBillingPlans: () => ({
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||
useBillingPlans: () => ({
|
||||
get plans() {
|
||||
return mockPlans
|
||||
},
|
||||
currentPlanSlug: { value: null },
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
@@ -88,6 +111,9 @@ describe('useBillingContext', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
@@ -161,4 +187,51 @@ describe('useBillingContext', () => {
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('standard')).toBe(1)
|
||||
expect(getMaxSeats('creator')).toBe(1)
|
||||
expect(getMaxSeats('pro')).toBe(1)
|
||||
expect(getMaxSeats('founder')).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to hardcoded values when no API plans available', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('standard')).toBe(1)
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
expect(getMaxSeats('pro')).toBe(20)
|
||||
expect(getMaxSeats('founder')).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers API max_seats when plans are loaded', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockPlans.value = [
|
||||
{
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 2110000,
|
||||
max_seats: 50,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 10000,
|
||||
total_credits_cents: 2110000
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('pro')).toBe(50)
|
||||
// Tiers without API plans still fall back to hardcoded values
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
@@ -115,6 +120,16 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
const apiTier = KEY_TO_TIER[tierKey]
|
||||
const plan = plans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
|
||||
)
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
@@ -223,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
fetchStatus,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
@@ -73,6 +74,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
@@ -582,7 +584,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
void dialogService.showSettingsDialog()
|
||||
settingsDialog.show()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -831,7 +833,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'About ComfyUI',
|
||||
versionAdded: '1.6.4',
|
||||
function: () => {
|
||||
void dialogService.showSettingsDialog('about')
|
||||
settingsDialog.showAbout()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ export function useFeatureFlags() {
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
|
||||
@@ -7,8 +7,13 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isImageNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, usePaste } from './usePaste'
|
||||
import { createNode, isImageNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
cloneDataTransfer,
|
||||
pasteImageNode,
|
||||
pasteImageNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
|
||||
function createMockNode() {
|
||||
return {
|
||||
@@ -86,6 +91,7 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
createNode: vi.fn(),
|
||||
isAudioNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
@@ -99,34 +105,32 @@ describe('pasteImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should create new LoadImage node when no image node provided', () => {
|
||||
it('should create new LoadImage node when no image node provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items
|
||||
)
|
||||
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pos).toEqual([100, 200])
|
||||
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', () => {
|
||||
it('should use existing image node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -136,13 +140,13 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should handle multiple image files', () => {
|
||||
it('should handle multiple image files', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = createDataTransfer([file1, file2])
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -152,11 +156,11 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
|
||||
})
|
||||
|
||||
it('should do nothing when no image files present', () => {
|
||||
it('should do nothing when no image files present', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -166,13 +170,13 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter non-image items', () => {
|
||||
it('should filter non-image items', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const imageFile = createImageFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, imageFile])
|
||||
|
||||
pasteImageNode(
|
||||
await pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
@@ -183,21 +187,61 @@ describe('pasteImageNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteImageNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create multiple nodes for multiple files', async () => {
|
||||
const mockNode1 = createMockNode()
|
||||
const mockNode2 = createMockNode()
|
||||
vi.mocked(createNode)
|
||||
.mockResolvedValueOnce(mockNode1 as unknown as LGraphNode)
|
||||
.mockResolvedValueOnce(mockNode2 as unknown as LGraphNode)
|
||||
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const fileList = createDataTransfer([file1, file2]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
fileList
|
||||
)
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
|
||||
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
|
||||
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const fileList = createDataTransfer([]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
fileList
|
||||
)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvas.current_node = null
|
||||
mockWorkspaceStore.shiftDown = false
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle image paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
|
||||
|
||||
usePaste()
|
||||
|
||||
@@ -207,7 +251,7 @@ describe('usePaste', () => {
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
@@ -312,3 +356,62 @@ describe('usePaste', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneDataTransfer', () => {
|
||||
it('should clone string data', () => {
|
||||
const original = new DataTransfer()
|
||||
original.setData('text/plain', 'test text')
|
||||
original.setData('text/html', '<p>test html</p>')
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.getData('text/plain')).toBe('test text')
|
||||
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
|
||||
})
|
||||
|
||||
it('should clone files', () => {
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const original = createDataTransfer([file1, file2])
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
// Files are added from both .files and .items, causing duplicates
|
||||
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
|
||||
expect(Array.from(cloned.files)).toContain(file1)
|
||||
expect(Array.from(cloned.files)).toContain(file2)
|
||||
})
|
||||
|
||||
it('should preserve dropEffect and effectAllowed', () => {
|
||||
const original = new DataTransfer()
|
||||
original.dropEffect = 'copy'
|
||||
original.effectAllowed = 'copyMove'
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.dropEffect).toBe('copy')
|
||||
expect(cloned.effectAllowed).toBe('copyMove')
|
||||
})
|
||||
|
||||
it('should handle empty DataTransfer', () => {
|
||||
const original = new DataTransfer()
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.types.length).toBe(0)
|
||||
expect(cloned.files.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should clone both string data and files', () => {
|
||||
const file = createImageFile()
|
||||
const original = createDataTransfer([file])
|
||||
original.setData('text/plain', 'test')
|
||||
|
||||
const cloned = cloneDataTransfer(original)
|
||||
|
||||
expect(cloned.getData('text/plain')).toBe('test')
|
||||
// Files are added from both .files and .items
|
||||
expect(cloned.files.length).toBeGreaterThanOrEqual(1)
|
||||
expect(Array.from(cloned.files)).toContain(file)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,41 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
createNode,
|
||||
isAudioNode,
|
||||
isImageNode,
|
||||
isVideoNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
|
||||
export function cloneDataTransfer(original: DataTransfer): DataTransfer {
|
||||
const persistent = new DataTransfer()
|
||||
|
||||
// Copy string data
|
||||
for (const type of original.types) {
|
||||
const data = original.getData(type)
|
||||
if (data) {
|
||||
persistent.setData(type, data)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of original.items) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
persistent.items.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve dropEffect and effectAllowed
|
||||
persistent.dropEffect = original.dropEffect
|
||||
persistent.effectAllowed = original.effectAllowed
|
||||
|
||||
return persistent
|
||||
}
|
||||
|
||||
function pasteClipboardItems(data: DataTransfer): boolean {
|
||||
const rawData = data.getData('text/html')
|
||||
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
@@ -48,27 +80,37 @@ function pasteItemsOnNode(
|
||||
)
|
||||
}
|
||||
|
||||
export function pasteImageNode(
|
||||
export async function pasteImageNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
imageNode: LGraphNode | null = null
|
||||
): void {
|
||||
const {
|
||||
graph,
|
||||
graph_mouse: [posX, posY]
|
||||
} = canvas
|
||||
|
||||
): Promise<LGraphNode | null> {
|
||||
// No image node selected: add a new one
|
||||
if (!imageNode) {
|
||||
// No image node selected: add a new one
|
||||
const newNode = LiteGraph.createNode('LoadImage')
|
||||
if (newNode) {
|
||||
newNode.pos = [posX, posY]
|
||||
imageNode = graph?.add(newNode) ?? null
|
||||
}
|
||||
graph?.change()
|
||||
imageNode = await createNode(canvas, 'LoadImage')
|
||||
}
|
||||
|
||||
pasteItemsOnNode(items, imageNode, 'image')
|
||||
return imageNode
|
||||
}
|
||||
|
||||
export async function pasteImageNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: FileList
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
for (const file of fileList) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
const imageNode = await pasteImageNode(canvas, transfer.items)
|
||||
|
||||
if (imageNode) {
|
||||
nodes.push(imageNode)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +135,7 @@ export const usePaste = () => {
|
||||
const { graph } = canvas
|
||||
let data: DataTransfer | string | null = e.clipboardData
|
||||
if (!data) throw new Error('No clipboard data on clipboard event')
|
||||
data = cloneDataTransfer(data)
|
||||
|
||||
const { items } = data
|
||||
|
||||
@@ -114,7 +157,7 @@ export const usePaste = () => {
|
||||
// Look for image paste data
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
return
|
||||
} else if (item.type.startsWith('video/')) {
|
||||
if (!videoNode) {
|
||||
|
||||
@@ -32,15 +32,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
props: {
|
||||
onClose: hide,
|
||||
initialCategory
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
content: { class: '!px-0 overflow-hidden h-full !py-0' },
|
||||
root: {
|
||||
style:
|
||||
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
|
||||
|
||||
/**
|
||||
* Returns whether the current environment is staging.
|
||||
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
*/
|
||||
export const isStaging = computed(() => {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_IS_STAGING
|
||||
}
|
||||
|
||||
const projectId = remoteConfig.value.firebase_config?.projectId
|
||||
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
|
||||
})
|
||||
@@ -71,6 +71,7 @@ async function uploadFile(
|
||||
api.apiURL(getResourceURL(...splitFilePath(path)))
|
||||
)
|
||||
|
||||
audioWidget.value = path
|
||||
// Manually trigger the callback to update VueNodes
|
||||
audioWidget.callback?.(path)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ describe('LGraph', () => {
|
||||
|
||||
expect(result1).toEqual(result2)
|
||||
})
|
||||
|
||||
it('should handle adding null node gracefully', () => {
|
||||
const graph = new LGraph()
|
||||
const initialNodeCount = graph.nodes.length
|
||||
|
||||
const result = graph.add(null)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(graph.nodes.length).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('can be instantiated', ({ expect }) => {
|
||||
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
|
||||
const graph = new LGraph({ extra: 'TestGraph' })
|
||||
|
||||
@@ -896,7 +896,7 @@ export class LGraph
|
||||
* @deprecated Use options object instead
|
||||
*/
|
||||
add(
|
||||
node: LGraphNode | LGraphGroup,
|
||||
node: LGraphNode | LGraphGroup | null,
|
||||
skipComputeOrder?: boolean
|
||||
): LGraphNode | null | undefined
|
||||
add(
|
||||
|
||||
@@ -2176,7 +2176,7 @@
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"descriptionWorkspace": "Choose the best plan for your workspace",
|
||||
@@ -2264,7 +2264,7 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/50 Members",
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2280,6 +2280,9 @@
|
||||
"revokeInvite": "Revoke invite",
|
||||
"removeMember": "Remove member"
|
||||
},
|
||||
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
|
||||
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
|
||||
"viewPlans": "View plans",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
@@ -2318,6 +2321,14 @@
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
"titleNotSubscribed": "A subscription is required to invite members",
|
||||
"titleSingleSeat": "Your current plan supports a single seat",
|
||||
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
|
||||
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
|
||||
"viewPlans": "View Plans",
|
||||
"upgradeToCreator": "Upgrade to Creator"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
@@ -2964,8 +2975,9 @@
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to {workspaceName}",
|
||||
"inviteFailed": "Failed to Accept Invite"
|
||||
"addedToWorkspace": "You have been added to:",
|
||||
"inviteFailed": "Failed to Accept Invite",
|
||||
"viewWorkspace": "View workspace"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
|
||||
@@ -86,15 +86,15 @@ import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
|
||||
const showSecretsHint = computed(() => flags.userSecretsEnabled)
|
||||
|
||||
function openSecretsSettings() {
|
||||
dialogService.showSettingsDialog('secrets')
|
||||
settingsDialog.show('secrets')
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
|
||||
interface ShowOptions {
|
||||
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||
@@ -22,64 +22,51 @@ interface BrowseOptions {
|
||||
onAssetSelected?: (asset: AssetItem) => void
|
||||
}
|
||||
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||
},
|
||||
header: {
|
||||
class: '!p-0 hidden'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 !m-0 h-full w-full'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
const DIALOG_KEY = 'global-asset-browser'
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogKey = 'global-asset-browser'
|
||||
|
||||
async function show(props: ShowOptions) {
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(props: ShowOptions) {
|
||||
const handleAssetSelected = (asset: AssetItem) => {
|
||||
props.onAssetSelected?.(asset)
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
hide()
|
||||
}
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: AssetBrowserModal,
|
||||
props: {
|
||||
nodeType: props.nodeType,
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
dialogComponentProps
|
||||
onClose: hide
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function browse(options: BrowseOptions): Promise<void> {
|
||||
function browse(options: BrowseOptions) {
|
||||
const handleAssetSelected = (asset: AssetItem) => {
|
||||
options.onAssetSelected?.(asset)
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
hide()
|
||||
}
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: AssetBrowserModal,
|
||||
props: {
|
||||
showLeftPanel: true,
|
||||
assetType: options.assetType,
|
||||
title: options.title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
dialogComponentProps
|
||||
onClose: hide
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +374,8 @@ const {
|
||||
plans: apiPlans,
|
||||
currentPlanSlug,
|
||||
fetchPlans,
|
||||
subscription
|
||||
subscription,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
@@ -404,11 +405,6 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
|
||||
return currentBillingCycle.value === 'yearly' ? price / 12 : price
|
||||
}
|
||||
|
||||
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
|
||||
const plan = getApiPlanForTier(tier.key, 'monthly')
|
||||
return plan ? plan.max_seats : null
|
||||
}
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
|
||||
)
|
||||
@@ -493,8 +489,7 @@ const getAnnualTotal = (tier: PricingTierConfig): number => {
|
||||
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
|
||||
}
|
||||
|
||||
const getMaxMembers = (tier: PricingTierConfig): number =>
|
||||
getMaxSeatsFromApi(tier) ?? tier.maxMembers
|
||||
const getMaxMembers = (tier: PricingTierConfig): number => getMaxSeats(tier.key)
|
||||
|
||||
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
|
||||
tier.pricing.credits
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabPanel value="PlanCredits" class="subscription-container h-full">
|
||||
<div class="subscription-container h-full">
|
||||
<div class="flex h-full flex-col gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl font-inter font-semibold leading-tight">
|
||||
@@ -63,11 +63,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
|
||||
@@ -88,10 +88,13 @@
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base"
|
||||
>{{ $t('subscription.perMonth') }} /
|
||||
{{ $t('subscription.member') }}</span
|
||||
>
|
||||
<span class="text-base">
|
||||
{{
|
||||
isInPersonalWorkspace
|
||||
? $t('subscription.usdPerMonth')
|
||||
: $t('subscription.usdPerMonthPerMember')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
@@ -176,7 +179,7 @@
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-3 h-full">
|
||||
<div
|
||||
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-modal-panel-background justify-between h-full"
|
||||
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-secondary-background justify-between h-full"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -359,7 +362,6 @@ import { useSubscriptionActions } from '@/platform/cloud/subscription/composable
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
@@ -388,7 +390,7 @@ const {
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
plans: apiPlans
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
@@ -511,23 +513,6 @@ const tierPrice = computed(() =>
|
||||
const memberCount = computed(() => members.value.length)
|
||||
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
|
||||
|
||||
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
|
||||
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase()
|
||||
return apiPlans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
}
|
||||
|
||||
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
|
||||
const plan = getApiPlanForTier(tierKey, 'monthly')
|
||||
return plan ? plan.max_seats : null
|
||||
}
|
||||
|
||||
function getMaxMembers(tierKey: TierKey): number {
|
||||
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const date = new Date(subscription.value.renewalDate)
|
||||
@@ -571,13 +556,18 @@ interface Benefit {
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
const benefits: Benefit[] = []
|
||||
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
benefits.push({
|
||||
key: 'members',
|
||||
type: 'icon',
|
||||
label: t('subscription.membersLabel', { count: getMaxMembers(key) }),
|
||||
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
|
||||
icon: 'pi pi-user'
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
benefits.push(
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
@@ -594,7 +584,7 @@ const tierBenefits = computed((): Benefit[] => {
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
|
||||
@@ -11,6 +11,13 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
}
|
||||
|
||||
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
|
||||
standard: 'STANDARD',
|
||||
creator: 'CREATOR',
|
||||
pro: 'PRO',
|
||||
founder: 'FOUNDERS_EDITION'
|
||||
}
|
||||
|
||||
export interface TierPricing {
|
||||
monthly: number
|
||||
yearly: number
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
<template>
|
||||
<PanelTemplate value="Extension" class="extension-panel">
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.extensions') })
|
||||
"
|
||||
/>
|
||||
<Message
|
||||
v-if="hasChanges"
|
||||
severity="info"
|
||||
pt:text="w-full"
|
||||
class="max-h-96 overflow-y-auto"
|
||||
>
|
||||
<ul>
|
||||
<li v-for="ext in changedExtensions" :key="ext.name">
|
||||
<span>
|
||||
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
|
||||
</span>
|
||||
{{ ext.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="destructive" @click="applyChanges">
|
||||
{{ $t('g.reloadToApplyChanges') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
<div class="extension-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.extensions') })"
|
||||
/>
|
||||
<Message
|
||||
v-if="hasChanges"
|
||||
severity="info"
|
||||
pt:text="w-full"
|
||||
class="max-h-96 overflow-y-auto"
|
||||
>
|
||||
<ul>
|
||||
<li v-for="ext in changedExtensions" :key="ext.name">
|
||||
<span>
|
||||
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
|
||||
</span>
|
||||
{{ ext.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="destructive" @click="applyChanges">
|
||||
{{ $t('g.reloadToApplyChanges') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Message>
|
||||
<div class="mb-3 flex gap-2">
|
||||
<SelectButton
|
||||
v-model="filterType"
|
||||
@@ -81,7 +77,7 @@
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -97,7 +93,6 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
@@ -1,46 +1,39 @@
|
||||
<template>
|
||||
<PanelTemplate value="Server-Config" class="server-config-panel">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Message
|
||||
v-if="modifiedConfigs.length > 0"
|
||||
severity="info"
|
||||
pt:text="w-full"
|
||||
>
|
||||
<p>
|
||||
{{ $t('serverConfig.modifiedConfigs') }}
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="config in modifiedConfigs" :key="config.id">
|
||||
{{ config.name }}: {{ config.initialValue }} → {{ config.value }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="secondary" @click="revertChanges">
|
||||
{{ $t('serverConfig.revertChanges') }}
|
||||
</Button>
|
||||
<Button variant="destructive" @click="restartApp">
|
||||
{{ $t('serverConfig.restart') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Message>
|
||||
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--terminal] text-xl font-bold" />
|
||||
</template>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>{{ commandLineArgs }}</p>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
@click="copyCommandLineArgs"
|
||||
>
|
||||
<i class="pi pi-clipboard" />
|
||||
</Button>
|
||||
</div>
|
||||
</Message>
|
||||
<div class="server-config-panel flex flex-col gap-2">
|
||||
<Message v-if="modifiedConfigs.length > 0" severity="info" pt:text="w-full">
|
||||
<p>
|
||||
{{ $t('serverConfig.modifiedConfigs') }}
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="config in modifiedConfigs" :key="config.id">
|
||||
{{ config.name }}: {{ config.initialValue }} → {{ config.value }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="secondary" @click="revertChanges">
|
||||
{{ $t('serverConfig.revertChanges') }}
|
||||
</Button>
|
||||
<Button variant="destructive" @click="restartApp">
|
||||
{{ $t('serverConfig.restart') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Message>
|
||||
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--terminal] text-xl font-bold" />
|
||||
</template>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>{{ commandLineArgs }}</p>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.copyToClipboard')"
|
||||
@click="copyCommandLineArgs"
|
||||
>
|
||||
<i class="pi pi-clipboard" />
|
||||
</Button>
|
||||
</div>
|
||||
</Message>
|
||||
<div
|
||||
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
|
||||
:key="label"
|
||||
@@ -58,7 +51,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -69,7 +62,6 @@ import { onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
|
||||
|
||||
200
src/platform/settings/components/SettingDialog.vue
Normal file
200
src/platform/settings/components/SettingDialog.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<div class="px-3">
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
size="md"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
ref="navRef"
|
||||
class="scrollbar-hide flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
|
||||
>
|
||||
<div
|
||||
v-for="(group, index) in navGroups"
|
||||
:key="index"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<NavTitle :title="group.title" />
|
||||
<NavItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:data-nav-id="item.id"
|
||||
:icon="item.icon"
|
||||
:badge="item.badge"
|
||||
:active="activeCategoryKey === item.id"
|
||||
@click="onNavItemClick(item.id)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NavItem>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<template #header />
|
||||
|
||||
<template #content>
|
||||
<template v-if="inSearch">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
</template>
|
||||
<template v-else-if="activeSettingCategory">
|
||||
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
|
||||
<ColorPaletteMessage
|
||||
v-if="activeSettingCategory.label === 'Appearance'"
|
||||
/>
|
||||
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
|
||||
</template>
|
||||
<template v-else-if="activePanel">
|
||||
<Suspense>
|
||||
<component :is="activePanel.component" v-bind="activePanel.props" />
|
||||
<template #fallback>
|
||||
<div>
|
||||
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, provide, ref, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
ISettingGroup,
|
||||
SettingPanelType,
|
||||
SettingParams
|
||||
} from '@/platform/settings/types'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
const { onClose, defaultPanel, scrollToSettingId } = defineProps<{
|
||||
onClose: () => void
|
||||
defaultPanel?: SettingPanelType
|
||||
scrollToSettingId?: string
|
||||
}>()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const {
|
||||
defaultCategory,
|
||||
settingCategories,
|
||||
navGroups,
|
||||
findCategoryByKey,
|
||||
findPanelByKey
|
||||
} = useSettingUI(defaultPanel, scrollToSettingId)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories,
|
||||
handleSearch: handleSearchBase,
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
watch(searchResultsCategories, (categories) => {
|
||||
if (!inSearch.value || categories.size === 0) return
|
||||
const firstMatch = navGroups.value
|
||||
.flatMap((g) => g.items)
|
||||
.find((item) => {
|
||||
const node = findCategoryByKey(item.id)
|
||||
return node && categories.has(node.label)
|
||||
})
|
||||
activeCategoryKey.value = firstMatch?.id ?? null
|
||||
})
|
||||
|
||||
const activeSettingCategory = computed<SettingTreeNode | null>(() => {
|
||||
if (!activeCategoryKey.value) return null
|
||||
return (
|
||||
settingCategories.value.find((c) => c.key === activeCategoryKey.value) ??
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const activePanel = computed(() => {
|
||||
if (!activeCategoryKey.value) return null
|
||||
return findPanelByKey(activeCategoryKey.value)
|
||||
})
|
||||
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
function sortedGroups(category: SettingTreeNode): ISettingGroup[] {
|
||||
return [...(category.children ?? [])]
|
||||
.sort((a, b) => {
|
||||
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
|
||||
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
|
||||
})
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
settings: flattenTree<SettingParams>(group).sort((a, b) => {
|
||||
const sortOrderA = a.sortOrder ?? 0
|
||||
const sortOrderB = b.sortOrder ?? 0
|
||||
return sortOrderB - sortOrderA
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
handleSearchBase(query.trim())
|
||||
if (query) {
|
||||
activeCategoryKey.value = null
|
||||
} else if (!activeCategoryKey.value) {
|
||||
activeCategoryKey.value = defaultCategory.value?.key ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function onNavItemClick(id: string) {
|
||||
activeCategoryKey.value = id
|
||||
}
|
||||
|
||||
const searchResults = computed<ISettingGroup[]>(() => {
|
||||
const category = activeCategoryKey.value
|
||||
? findCategoryByKey(activeCategoryKey.value)
|
||||
: null
|
||||
return getSearchResults(category)
|
||||
})
|
||||
|
||||
watch(activeCategoryKey, (newKey, oldKey) => {
|
||||
if (!newKey && !inSearch.value) {
|
||||
activeCategoryKey.value = oldKey
|
||||
}
|
||||
if (newKey === 'credits') {
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
if (newKey) {
|
||||
void nextTick(() => {
|
||||
navRef.value
|
||||
?.querySelector(`[data-nav-id="${newKey}"]`)
|
||||
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,303 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="settings-dialog"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'flex h-full w-full overflow-auto flex-col md:flex-row'
|
||||
: 'settings-container'
|
||||
"
|
||||
>
|
||||
<ScrollPanel
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-full md:w-64 md:min-w-64 md:max-w-64 shrink-0 p-2'
|
||||
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
|
||||
"
|
||||
>
|
||||
<div :class="teamWorkspacesEnabled ? 'px-4' : ''">
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="settings-search-box mb-2 w-full"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.settings') })
|
||||
"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<Listbox
|
||||
v-model="activeCategory"
|
||||
:options="groupedMenuTreeNodes"
|
||||
option-label="translatedLabel"
|
||||
option-group-label="label"
|
||||
option-group-children="children"
|
||||
scroll-height="100%"
|
||||
:option-disabled="
|
||||
(option: SettingTreeNode) =>
|
||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
||||
"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-full border-none bg-transparent'
|
||||
: 'w-full border-none'
|
||||
"
|
||||
>
|
||||
<!-- Workspace mode: custom group headers -->
|
||||
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
|
||||
{{ option.translatedLabel ?? option.label }}
|
||||
</h3>
|
||||
</template>
|
||||
<!-- Legacy mode: divider between groups -->
|
||||
<template v-else #optiongroup>
|
||||
<Divider class="my-0" />
|
||||
</template>
|
||||
<!-- Custom option template with data-testid for stable test selectors -->
|
||||
<template #option="{ option }">
|
||||
<span
|
||||
:data-testid="`settings-tab-${option.key}`"
|
||||
class="settings-tab-option"
|
||||
>
|
||||
<WorkspaceSidebarItem
|
||||
v-if="teamWorkspacesEnabled && option.key === 'workspace'"
|
||||
/>
|
||||
<template v-else>{{ option.translatedLabel }}</template>
|
||||
</span>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
||||
<Divider layout="horizontal" class="flex md:hidden" />
|
||||
<Tabs
|
||||
:value="tabValue"
|
||||
:lazy="true"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'h-full flex-1 overflow-auto scrollbar-custom'
|
||||
: 'settings-content h-full w-full'
|
||||
"
|
||||
>
|
||||
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||
<PanelTemplate value="Search Results">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
</PanelTemplate>
|
||||
|
||||
<PanelTemplate
|
||||
v-for="category in settingCategories"
|
||||
:key="category.key"
|
||||
:value="category.label ?? ''"
|
||||
>
|
||||
<template #header>
|
||||
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
|
||||
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
|
||||
</template>
|
||||
<SettingsPanel :setting-groups="sortedGroups(category)" />
|
||||
</PanelTemplate>
|
||||
|
||||
<Suspense v-for="panel in panels" :key="panel.node.key">
|
||||
<component :is="panel.component" v-bind="panel.props" />
|
||||
<template #fallback>
|
||||
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, nextTick, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
const { defaultPanel, scrollToSettingId } = defineProps<{
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets'
|
||||
scrollToSettingId?: string
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const {
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
settingCategories,
|
||||
groupedMenuTreeNodes,
|
||||
panels
|
||||
} = useSettingUI(defaultPanel, scrollToSettingId)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
searchResultsCategories,
|
||||
queryIsEmpty,
|
||||
inSearch,
|
||||
handleSearch: handleSearchBase,
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
// Get max sortOrder from settings in a group
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
// Sort groups for a category
|
||||
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
return [...(category.children ?? [])]
|
||||
.sort((a, b) => {
|
||||
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
|
||||
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
|
||||
})
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
settings: flattenTree<SettingParams>(group).sort((a, b) => {
|
||||
const sortOrderA = a.sortOrder ?? 0
|
||||
const sortOrderB = b.sortOrder ?? 0
|
||||
|
||||
return sortOrderB - sortOrderA
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
handleSearchBase(query.trim())
|
||||
activeCategory.value = query ? null : defaultCategory.value
|
||||
}
|
||||
|
||||
// Get search results
|
||||
const searchResults = computed<ISettingGroup[]>(() =>
|
||||
getSearchResults(activeCategory.value)
|
||||
)
|
||||
|
||||
const tabValue = computed<string>(() =>
|
||||
inSearch.value ? 'Search Results' : (activeCategory.value?.label ?? '')
|
||||
)
|
||||
|
||||
// Scroll to and highlight the target setting once the correct tab renders.
|
||||
if (scrollToSettingId) {
|
||||
const stopScrollWatch = watch(
|
||||
tabValue,
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-setting-id="${CSS.escape(scrollToSettingId)}"]`
|
||||
)
|
||||
if (!el) return
|
||||
stopScrollWatch()
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('setting-highlight')
|
||||
el.addEventListener(
|
||||
'animationend',
|
||||
() => el.classList.remove('setting-highlight'),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onBeforeUnmount(stopScrollWatch)
|
||||
}
|
||||
|
||||
// Don't allow null category to be set outside of search.
|
||||
// In search mode, the active category can be null to show all search results.
|
||||
watch(activeCategory, (_, oldValue) => {
|
||||
if (!tabValue.value) {
|
||||
activeCategory.value = oldValue
|
||||
}
|
||||
if (activeCategory.value?.key === 'credits') {
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-tab-panels {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.setting-highlight {
|
||||
animation: setting-highlight-pulse 1.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes setting-highlight-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
30% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--p-primary-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
width: 60vw;
|
||||
max-width: 64rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
width: 80vw;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
height: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the first group separator in legacy mode */
|
||||
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -12,10 +12,30 @@ import {
|
||||
useSettingStore
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
|
||||
import type { NavGroupData } from '@/types/navTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
'3D': 'icon-[lucide--box]',
|
||||
about: 'icon-[lucide--info]',
|
||||
Appearance: 'icon-[lucide--palette]',
|
||||
Comfy: 'icon-[lucide--settings]',
|
||||
credits: 'icon-[lucide--coins]',
|
||||
extension: 'icon-[lucide--puzzle]',
|
||||
keybinding: 'icon-[lucide--keyboard]',
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
subscription: 'icon-[lucide--credit-card]',
|
||||
user: 'icon-[lucide--user]',
|
||||
workspace: 'icon-[lucide--building-2]'
|
||||
}
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
component: Component
|
||||
@@ -23,16 +43,7 @@ interface SettingPanelItem {
|
||||
}
|
||||
|
||||
export function useSettingUI(
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets',
|
||||
defaultPanel?: SettingPanelType,
|
||||
scrollToSettingId?: string
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
@@ -165,7 +176,8 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
|
||||
() =>
|
||||
import('@/components/dialog/content/setting/WorkspacePanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,6 +369,36 @@ export function useSettingUI(
|
||||
: legacyMenuTreeNodes.value
|
||||
)
|
||||
|
||||
const navGroups = computed<NavGroupData[]>(() =>
|
||||
groupedMenuTreeNodes.value.map((group) => ({
|
||||
title:
|
||||
(group as SettingTreeNode & { translatedLabel?: string })
|
||||
.translatedLabel ?? group.label,
|
||||
items: (group.children ?? []).map((child) => ({
|
||||
id: child.key,
|
||||
label:
|
||||
(child as SettingTreeNode & { translatedLabel?: string })
|
||||
.translatedLabel ?? child.label,
|
||||
icon:
|
||||
CATEGORY_ICONS[child.key] ??
|
||||
CATEGORY_ICONS[child.label] ??
|
||||
'icon-[lucide--plug]'
|
||||
}))
|
||||
}))
|
||||
)
|
||||
|
||||
function findCategoryByKey(key: string): SettingTreeNode | null {
|
||||
for (const group of groupedMenuTreeNodes.value) {
|
||||
const found = group.children?.find((node) => node.key === key)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPanelByKey(key: string): SettingPanelItem | null {
|
||||
return panels.value.find((p) => p.node.key === key) ?? null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeCategory.value = defaultCategory.value
|
||||
})
|
||||
@@ -366,6 +408,10 @@ export function useSettingUI(
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
groupedMenuTreeNodes,
|
||||
settingCategories
|
||||
settingCategories,
|
||||
navGroups,
|
||||
teamWorkspacesEnabled,
|
||||
findCategoryByKey,
|
||||
findPanelByKey
|
||||
}
|
||||
}
|
||||
|
||||
34
src/platform/settings/composables/useSettingsDialog.ts
Normal file
34
src/platform/settings/composables/useSettingsDialog.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import SettingDialog from '@/platform/settings/components/SettingDialog.vue'
|
||||
import type { SettingPanelType } from '@/platform/settings/types'
|
||||
|
||||
const DIALOG_KEY = 'global-settings'
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show(panel?: SettingPanelType, settingId?: string) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: SettingDialog,
|
||||
props: {
|
||||
onClose: hide,
|
||||
...(panel ? { defaultPanel: panel } : {}),
|
||||
...(settingId ? { scrollToSettingId: settingId } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showAbout() {
|
||||
show('about')
|
||||
}
|
||||
|
||||
return { show, hide, showAbout }
|
||||
}
|
||||
@@ -64,3 +64,14 @@ export interface ISettingGroup {
|
||||
label: string
|
||||
settings: SettingParams[]
|
||||
}
|
||||
|
||||
export type SettingPanelType =
|
||||
| 'about'
|
||||
| 'credits'
|
||||
| 'extension'
|
||||
| 'keybinding'
|
||||
| 'secrets'
|
||||
| 'server-config'
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
|
||||
@@ -239,15 +239,6 @@ interface CreateTopupResponse {
|
||||
amount_cents: number
|
||||
}
|
||||
|
||||
interface TopupStatusResponse {
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
amount_cents: number
|
||||
error_message?: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
|
||||
|
||||
export interface BillingOpStatusResponse {
|
||||
@@ -701,23 +692,6 @@ export const workspaceApi = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get top-up status
|
||||
* GET /api/billing/topup/:id
|
||||
*/
|
||||
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.get<TopupStatusResponse>(
|
||||
api.apiURL(`/billing/topup/${topupId}`),
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get billing events
|
||||
* GET /api/billing/events
|
||||
|
||||
@@ -130,8 +130,13 @@ describe('useInviteUrlLoader', () => {
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'Invite Accepted',
|
||||
detail: 'You have been added to Test Workspace',
|
||||
life: 5000
|
||||
detail: {
|
||||
text: 'You have been added to Test Workspace',
|
||||
workspaceId: 'ws-123',
|
||||
workspaceName: 'Test Workspace'
|
||||
},
|
||||
group: 'invite-accepted',
|
||||
closable: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -81,12 +81,17 @@ export function useInviteUrlLoader() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspace.inviteAccepted'),
|
||||
detail: t(
|
||||
'workspace.addedToWorkspace',
|
||||
{ workspaceName: result.workspaceName },
|
||||
{ escapeParameter: false }
|
||||
),
|
||||
life: 5000
|
||||
detail: {
|
||||
text: t(
|
||||
'workspace.addedToWorkspace',
|
||||
{ workspaceName: result.workspaceName },
|
||||
{ escapeParameter: false }
|
||||
),
|
||||
workspaceName: result.workspaceName,
|
||||
workspaceId: result.workspaceId
|
||||
},
|
||||
group: 'invite-accepted',
|
||||
closable: true
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
@@ -137,13 +137,6 @@ function getUIConfig(
|
||||
function useWorkspaceUIInternal() {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
// Tab management (shared UI state)
|
||||
const activeTab = ref<string>('plan')
|
||||
|
||||
function setActiveTab(tab: string | number) {
|
||||
activeTab.value = String(tab)
|
||||
}
|
||||
|
||||
const workspaceType = computed<WorkspaceType>(
|
||||
() => store.activeWorkspace?.type ?? 'personal'
|
||||
)
|
||||
@@ -161,10 +154,6 @@ function useWorkspaceUIInternal() {
|
||||
)
|
||||
|
||||
return {
|
||||
// Tab management
|
||||
activeTab: computed(() => activeTab.value),
|
||||
setActiveTab,
|
||||
|
||||
// Permissions and config
|
||||
permissions,
|
||||
uiConfig,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,max-content)_minmax(125px,auto)] gap-y-1 pr-3',
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
|
||||
@@ -34,5 +34,11 @@ onMounted(() => {
|
||||
whenever(() => !canvasStore.linearMode, mountWidgetElement)
|
||||
</script>
|
||||
<template>
|
||||
<div ref="domEl" @pointerdown.stop @pointermove.stop @pointerup.stop />
|
||||
<div
|
||||
ref="domEl"
|
||||
class="flex flex-col [&>*]:flex-1"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -59,5 +59,7 @@ const filteredProps = computed(() =>
|
||||
const displayName = computed(() => widget.label || widget.name)
|
||||
const id = useId()
|
||||
|
||||
const isReadOnly = computed(() => widget.options?.read_only ?? false)
|
||||
const isReadOnly = computed(
|
||||
() => widget.options?.read_only ?? widget.options?.disabled ?? false
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -7,6 +5,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type TextPreviewCustomProps = Omit<
|
||||
InstanceType<typeof TextPreviewWidget>['$props'],
|
||||
@@ -24,7 +23,6 @@ export function useTextPreviewWidget(
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
): IBaseWidget {
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
TextPreviewCustomProps
|
||||
@@ -37,9 +35,16 @@ export function useTextPreviewWidget(
|
||||
nodeId: node.id
|
||||
},
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
getValue: () =>
|
||||
useWidgetValueStore().getWidget(node.id, inputSpec.name)?.value ?? '',
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)
|
||||
if (widgetState)
|
||||
widgetState.value =
|
||||
typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
read_only: true
|
||||
|
||||
200
src/scripts/app.test.ts
Normal file
200
src/scripts/app.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyApp } from './app'
|
||||
import { createNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
createNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn(),
|
||||
isAudioNode: vi.fn(),
|
||||
executeWidgetsCallback: vi.fn(),
|
||||
fixLinkInputSlots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePaste', () => ({
|
||||
pasteImageNode: vi.fn(),
|
||||
pasteImageNodes: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/metadata/parser', () => ({
|
||||
getWorkflowDataFromFile: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
addAlert: vi.fn(),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockNode(options: { [K in keyof LGraphNode]?: any } = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
type: 'LoadImage',
|
||||
connect: vi.fn(),
|
||||
getBounding: vi.fn(() => new Float64Array([0, 0, 200, 100])),
|
||||
...options
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
function createMockCanvas(): Partial<LGraphCanvas> {
|
||||
const mockGraph: Partial<LGraph> = {
|
||||
change: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
graph: mockGraph as LGraph,
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createTestFile(name: string, type: string): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
describe('ComfyApp', () => {
|
||||
let app: ComfyApp
|
||||
let mockCanvas: LGraphCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
app = new ComfyApp()
|
||||
mockCanvas = createMockCanvas() as LGraphCanvas
|
||||
app.canvas = mockCanvas as LGraphCanvas
|
||||
})
|
||||
|
||||
describe('handleFileList', () => {
|
||||
it('should create image nodes for each file in the list', async () => {
|
||||
const mockNode1 = createMockNode({ id: 1 })
|
||||
const mockNode2 = createMockNode({ id: 2 })
|
||||
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
|
||||
|
||||
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
|
||||
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
|
||||
|
||||
const file1 = createTestFile('test1.png', 'image/png')
|
||||
const file2 = createTestFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const { files } = dataTransfer
|
||||
|
||||
await app.handleFileList(files)
|
||||
|
||||
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
|
||||
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
|
||||
mockNode1,
|
||||
mockNode2,
|
||||
mockBatchNode
|
||||
])
|
||||
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
|
||||
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
|
||||
})
|
||||
|
||||
it('should not proceed if batch node creation fails', async () => {
|
||||
const mockNode1 = createMockNode({ id: 1 })
|
||||
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
|
||||
vi.mocked(createNode).mockResolvedValue(null)
|
||||
|
||||
const file = createTestFile('test.png', 'image/png')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
await app.handleFileList(dataTransfer.files)
|
||||
|
||||
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
|
||||
expect(mockNode1.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const dataTransfer = new DataTransfer()
|
||||
await expect(app.handleFileList(dataTransfer.files)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should not process unsupported file types', async () => {
|
||||
const invalidFile = createTestFile('test.pdf', 'application/pdf')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(invalidFile)
|
||||
|
||||
await app.handleFileList(dataTransfer.files)
|
||||
|
||||
expect(pasteImageNodes).not.toHaveBeenCalled()
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('positionBatchNodes', () => {
|
||||
it('should position batch node to the right of first node', () => {
|
||||
const mockNode1 = createMockNode({
|
||||
pos: [100, 200],
|
||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
||||
})
|
||||
const mockBatchNode = createMockNode({ pos: [0, 0] })
|
||||
|
||||
app.positionBatchNodes([mockNode1], mockBatchNode)
|
||||
|
||||
expect(mockBatchNode.pos).toEqual([500, 230])
|
||||
})
|
||||
|
||||
it('should stack multiple image nodes vertically', () => {
|
||||
const mockNode1 = createMockNode({
|
||||
pos: [100, 200],
|
||||
type: 'LoadImage',
|
||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
||||
})
|
||||
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
||||
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
|
||||
const mockBatchNode = createMockNode({ pos: [0, 0] })
|
||||
|
||||
app.positionBatchNodes([mockNode1, mockNode2, mockNode3], mockBatchNode)
|
||||
|
||||
expect(mockNode1.pos).toEqual([100, 200])
|
||||
expect(mockNode2.pos).toEqual([100, 594])
|
||||
expect(mockNode3.pos).toEqual([100, 963])
|
||||
})
|
||||
|
||||
it('should call graph change once for all nodes', () => {
|
||||
const mockNode1 = createMockNode({
|
||||
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
|
||||
})
|
||||
const mockBatchNode = createMockNode()
|
||||
|
||||
app.positionBatchNodes([mockNode1], mockBatchNode)
|
||||
|
||||
expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleFile', () => {
|
||||
it('should handle image files by creating LoadImage node', async () => {
|
||||
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
|
||||
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(createNode).mockResolvedValue(mockNode)
|
||||
|
||||
const imageFile = createTestFile('test.png', 'image/png')
|
||||
|
||||
await app.handleFile(imageFile)
|
||||
|
||||
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
|
||||
expect(pasteImageNode).toHaveBeenCalledWith(
|
||||
mockCanvas,
|
||||
expect.any(DataTransferItemList),
|
||||
mockNode
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
executeWidgetsCallback,
|
||||
createNode,
|
||||
fixLinkInputSlots,
|
||||
isImageNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
@@ -108,7 +109,7 @@ import { type ComfyWidgetConstructor } from './widgets'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { extractFileFromDragEvent } from '@/utils/eventUtils'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { pasteImageNode } from '@/composables/usePaste'
|
||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
||||
|
||||
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
||||
|
||||
@@ -553,7 +554,13 @@ export class ComfyApp {
|
||||
const workspace = useWorkspaceStore()
|
||||
try {
|
||||
workspace.spinner = true
|
||||
await this.handleFile(fileMaybe, 'file_drop')
|
||||
if (fileMaybe instanceof File) {
|
||||
await this.handleFile(fileMaybe, 'file_drop')
|
||||
}
|
||||
|
||||
if (fileMaybe instanceof FileList) {
|
||||
await this.handleFileList(fileMaybe)
|
||||
}
|
||||
} finally {
|
||||
workspace.spinner = false
|
||||
}
|
||||
@@ -1488,7 +1495,8 @@ export class ComfyApp {
|
||||
if (file.type.startsWith('image')) {
|
||||
const transfer = new DataTransfer()
|
||||
transfer.items.add(file)
|
||||
pasteImageNode(this.canvas, transfer.items)
|
||||
const imageNode = await createNode(this.canvas, 'LoadImage')
|
||||
await pasteImageNode(this.canvas, transfer.items, imageNode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1567,6 +1575,49 @@ export class ComfyApp {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads multiple files, connects to a batch node, and selects them
|
||||
* @param {FileList} fileList
|
||||
*/
|
||||
async handleFileList(fileList: FileList) {
|
||||
if (fileList[0].type.startsWith('image')) {
|
||||
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
||||
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
||||
if (!batchImagesNode) return
|
||||
|
||||
this.positionBatchNodes(imageNodes, batchImagesNode)
|
||||
this.canvas.selectItems([...imageNodes, batchImagesNode])
|
||||
|
||||
Array.from(imageNodes).forEach((imageNode, index) => {
|
||||
imageNode.connect(0, batchImagesNode, index)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions batched nodes in drag and drop
|
||||
* @param nodes
|
||||
* @param batchNode
|
||||
*/
|
||||
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
|
||||
const [x, y, width] = nodes[0].getBounding()
|
||||
batchNode.pos = [x + width + 100, y + 30]
|
||||
|
||||
// Retrieving Node Height is inconsistent
|
||||
let height = 0
|
||||
if (nodes[0].type === 'LoadImage') {
|
||||
height = 344
|
||||
}
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
if (index > 0) {
|
||||
node.pos = [x, y + height * index + 25 * (index + 1)]
|
||||
}
|
||||
})
|
||||
|
||||
this.canvas.graph?.change()
|
||||
}
|
||||
|
||||
// @deprecated
|
||||
isApiJson(data: unknown): data is ComfyApiWorkflow {
|
||||
if (!_.isObject(data) || Array.isArray(data)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
|
||||
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
@@ -464,7 +464,7 @@ export class ComfyUI {
|
||||
$el('button.comfy-settings-btn', {
|
||||
textContent: '⚙️',
|
||||
onclick: () => {
|
||||
useDialogService().showSettingsDialog()
|
||||
useSettingsDialog().show()
|
||||
}
|
||||
}),
|
||||
$el('button.comfy-close-menu-btn', {
|
||||
|
||||
@@ -40,10 +40,6 @@ const lazyUpdatePasswordContent = () =>
|
||||
import('@/components/dialog/content/UpdatePasswordContent.vue')
|
||||
const lazyComfyOrgHeader = () =>
|
||||
import('@/components/dialog/header/ComfyOrgHeader.vue')
|
||||
const lazySettingDialogHeader = () =>
|
||||
import('@/components/dialog/header/SettingDialogHeader.vue')
|
||||
const lazySettingDialogContent = () =>
|
||||
import('@/platform/settings/components/SettingDialogContent.vue')
|
||||
const lazyImportFailedNodeContent = () =>
|
||||
import('@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue')
|
||||
const lazyImportFailedNodeHeader = () =>
|
||||
@@ -128,64 +124,6 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showSettingsDialog(
|
||||
panel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
| 'secrets',
|
||||
settingId?: string
|
||||
) {
|
||||
const [
|
||||
{ default: SettingDialogHeader },
|
||||
{ default: SettingDialogContent }
|
||||
] = await Promise.all([
|
||||
lazySettingDialogHeader(),
|
||||
lazySettingDialogContent()
|
||||
])
|
||||
|
||||
const props =
|
||||
panel || settingId
|
||||
? {
|
||||
props: {
|
||||
defaultPanel: panel,
|
||||
scrollToSettingId: settingId
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
...props
|
||||
})
|
||||
}
|
||||
|
||||
async function showAboutDialog() {
|
||||
const [
|
||||
{ default: SettingDialogHeader },
|
||||
{ default: SettingDialogContent }
|
||||
] = await Promise.all([
|
||||
lazySettingDialogHeader(),
|
||||
lazySettingDialogContent()
|
||||
])
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
props: {
|
||||
defaultPanel: 'about'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showExecutionErrorDialog(executionError: ExecutionErrorDialogInput) {
|
||||
const props: ComponentAttrs<typeof ErrorDialogContent> = {
|
||||
error: {
|
||||
@@ -476,7 +414,7 @@ export const useDialogService = () => {
|
||||
const layoutDefaultProps: DialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: false,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
@@ -711,6 +649,22 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showInviteMemberUpsellDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[512px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showRevokeInviteDialog(inviteId: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
|
||||
@@ -760,8 +714,6 @@ export const useDialogService = () => {
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
showSettingsDialog,
|
||||
showAboutDialog,
|
||||
showExecutionErrorDialog,
|
||||
showApiNodesSignInDialog,
|
||||
showSignInDialog,
|
||||
@@ -782,6 +734,7 @@ export const useDialogService = () => {
|
||||
showRemoveMemberDialog,
|
||||
showRevokeInviteDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog
|
||||
}
|
||||
|
||||
@@ -880,7 +880,6 @@ export const useLitegraphService = () => {
|
||||
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
graph.add(node)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return node
|
||||
|
||||
@@ -33,9 +33,11 @@ vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showSettingsDialog: vi.fn()
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: () => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const INITIAL_INTERVAL_MS = 1000
|
||||
@@ -143,7 +143,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.closeDialog({ key: 'subscription-required' })
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
void useDialogService().showSettingsDialog('workspace')
|
||||
useSettingsDialog().show('workspace')
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const messageKey =
|
||||
|
||||
@@ -119,7 +119,6 @@ describe('useFirebaseAuthStore', () => {
|
||||
|
||||
// Setup dialog service mock
|
||||
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
|
||||
showSettingsDialog: vi.fn(),
|
||||
showErrorDialog: vi.fn()
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,45 @@ describe('eventUtils', () => {
|
||||
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
|
||||
})
|
||||
|
||||
it('should handle drops with multiple image files', async () => {
|
||||
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
const imageFile2 = new File([new Uint8Array()], 'image2.jpg', {
|
||||
type: 'image/jpeg'
|
||||
})
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(imageFile1)
|
||||
dataTransfer.items.add(imageFile2)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBeDefined()
|
||||
expect((actual as FileList).length).toBe(2)
|
||||
expect((actual as FileList)[0]).toBe(imageFile1)
|
||||
expect((actual as FileList)[1]).toBe(imageFile2)
|
||||
})
|
||||
|
||||
it('should return undefined when dropping multiple non-image files', async () => {
|
||||
const file1 = new File([new Uint8Array()], 'file1.txt', {
|
||||
type: 'text/plain'
|
||||
})
|
||||
const file2 = new File([new Uint8Array()], 'file2.txt', {
|
||||
type: 'text/plain'
|
||||
})
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBe(undefined)
|
||||
})
|
||||
|
||||
// Skip until we can setup MSW
|
||||
it.skip('should handle drops with URLs', async () => {
|
||||
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export async function extractFileFromDragEvent(
|
||||
event: DragEvent
|
||||
): Promise<File | undefined> {
|
||||
): Promise<File | FileList | undefined> {
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
|
||||
if (
|
||||
event.dataTransfer.files.length &&
|
||||
event.dataTransfer.files[0].type !== 'image/bmp'
|
||||
) {
|
||||
return event.dataTransfer.files[0]
|
||||
const { files } = event.dataTransfer
|
||||
// Dragging from Chrome->Firefox there is a file, but it's a bmp, so ignore it
|
||||
if (files.length === 1 && files[0].type !== 'image/bmp') {
|
||||
return files[0]
|
||||
} else if (files.length > 1 && Array.from(files).every(hasImageType)) {
|
||||
return files
|
||||
}
|
||||
|
||||
// Try loading the first URI in the transfer list
|
||||
@@ -25,3 +25,7 @@ export async function extractFileFromDragEvent(
|
||||
const blob = await response.blob()
|
||||
return new File([blob], uri, { type: blob.type })
|
||||
}
|
||||
|
||||
function hasImageType({ type }: File): boolean {
|
||||
return type.startsWith('image')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
compressWidgetInputSlots,
|
||||
createNode,
|
||||
migrateWidgetsValues
|
||||
} from '@/utils/litegraphUtil'
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
createNode: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
addAlert: vi.fn(),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) => key)
|
||||
}))
|
||||
|
||||
function createMockCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
|
||||
const mockGraph = {
|
||||
add: vi.fn((node) => node),
|
||||
change: vi.fn()
|
||||
} satisfies Partial<LGraph> as unknown as LGraph
|
||||
const mockCanvas: Partial<LGraphCanvas> = {
|
||||
graph_mouse: [100, 200],
|
||||
graph: mockGraph,
|
||||
...overrides
|
||||
}
|
||||
return mockCanvas as LGraphCanvas
|
||||
}
|
||||
|
||||
describe('createNode', () => {
|
||||
beforeEach(vi.clearAllMocks)
|
||||
|
||||
it('should create a node successfully', async () => {
|
||||
const mockNode = { pos: [0, 0] }
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
|
||||
|
||||
const mockCanvas = createMockCanvas()
|
||||
const result = await createNode(mockCanvas, 'LoadImage')
|
||||
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pos).toEqual([100, 200])
|
||||
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
||||
expect(result).toBe(mockNode)
|
||||
})
|
||||
|
||||
it('should return null when name is empty', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const result = await createNode(mockCanvas, '')
|
||||
|
||||
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle graph being null', async () => {
|
||||
const mockNode = { pos: [0, 0] }
|
||||
const mockCanvas = createMockCanvas({ graph: null })
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
|
||||
|
||||
const result = await createNode(mockCanvas, 'LoadImage')
|
||||
|
||||
expect(mockNode.pos).toEqual([0, 0])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
it('should set position based on canvas graph_mouse', async () => {
|
||||
const mockCanvas = createMockCanvas({ graph_mouse: [250, 350] })
|
||||
const mockNode = { pos: [0, 0] }
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
|
||||
|
||||
await createNode(mockCanvas, 'LoadAudio')
|
||||
|
||||
expect(mockNode.pos).toEqual([250, 350])
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateWidgetsValues', () => {
|
||||
it('should remove widget values for forceInput inputs', () => {
|
||||
const inputDefs: Record<string, InputSpec> = {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ColorOption,
|
||||
LGraph,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
Reroute,
|
||||
isColorable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
@@ -18,6 +23,8 @@ import type {
|
||||
WidgetCallbackOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
|
||||
type VideoNode = LGraphNode & {
|
||||
@@ -25,6 +32,38 @@ type VideoNode = LGraphNode & {
|
||||
imgs: HTMLVideoElement[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract & Promisify Litegraph.createNode to allow for positioning
|
||||
* @param canvas
|
||||
* @param name
|
||||
*/
|
||||
export async function createNode(
|
||||
canvas: LGraphCanvas,
|
||||
name: string
|
||||
): Promise<LGraphNode | null> {
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
graph,
|
||||
graph_mouse: [posX, posY]
|
||||
} = canvas
|
||||
const newNode = LiteGraph.createNode(name)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
if (newNode && graph) {
|
||||
newNode.pos = [posX, posY]
|
||||
const addedNode = graph.add(newNode) ?? null
|
||||
|
||||
if (addedNode) graph.change()
|
||||
return addedNode
|
||||
} else {
|
||||
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
|
||||
if (!node) return false
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
<InviteAcceptedToast />
|
||||
<RerouteMigrationToast />
|
||||
<ModelImportProgressDialog />
|
||||
<ManagerProgressToast />
|
||||
@@ -44,6 +45,7 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
|
||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
|
||||
@@ -20,11 +20,6 @@ export function useManagerDialog() {
|
||||
props: {
|
||||
onClose: hide,
|
||||
initialTab
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
content: { class: '!px-0 overflow-hidden h-full !py-0' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,18 +28,13 @@ vi.mock('@/composables/useFeatureFlags', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/dialogService', () => {
|
||||
const showManagerPopup = vi.fn()
|
||||
const showLegacyManagerPopup = vi.fn()
|
||||
const showSettingsDialog = vi.fn()
|
||||
return {
|
||||
useDialogService: vi.fn(() => ({
|
||||
showManagerPopup,
|
||||
showLegacyManagerPopup,
|
||||
showSettingsDialog
|
||||
}))
|
||||
}
|
||||
})
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: vi.fn(() => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, readonly } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { useManagerDialog } from '@/workbench/extensions/manager/composables/useManagerDialog'
|
||||
@@ -148,12 +148,12 @@ export function useManagerState() {
|
||||
isLegacyOnly?: boolean
|
||||
}): Promise<void> => {
|
||||
const state = managerUIState.value
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
switch (state) {
|
||||
case ManagerUIState.DISABLED:
|
||||
void dialogService.showSettingsDialog('extension')
|
||||
settingsDialog.show('extension')
|
||||
break
|
||||
|
||||
case ManagerUIState.LEGACY_UI: {
|
||||
@@ -173,7 +173,7 @@ export function useManagerState() {
|
||||
}
|
||||
// Fallback to extensions panel if not showing toast
|
||||
if (options?.showToastOnLegacyError === false) {
|
||||
void dialogService.showSettingsDialog('extension')
|
||||
settingsDialog.show('extension')
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"rootDir": "./"
|
||||
},
|
||||
"include": [
|
||||
".pages/*.ts",
|
||||
".storybook/**/*",
|
||||
"eslint.config.ts",
|
||||
"global.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user