diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index d4a309bf8..4405aad1f 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -1,6 +1,5 @@ name: Bug Report description: 'Report something that is not working correctly' -title: '[Bug]: ' labels: ['Potential Bug'] type: Bug diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index a32598374..0d8173b28 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,7 +1,6 @@ name: Feature Request description: Report a problem or limitation you're experiencing -title: '[Feature]: ' -labels: ['enhancement'] +labels: [] type: Feature body: diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml deleted file mode 100644 index b49eb544a..000000000 --- a/.github/workflows/pr-checks.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: PR Checks -on: - pull_request: - types: [opened, edited, synchronize, reopened] - -permissions: - contents: read - pull-requests: read - -jobs: - analyze: - runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.check-changes.outputs.should_run }} - has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }} - has_screen_recording: ${{ steps.check-recording.outputs.has_recording }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Ensure base branch is available - run: | - # Fetch the specific base commit to ensure it's available for git diff - git fetch origin ${{ github.event.pull_request.base.sha }} - - - name: Check if significant changes exist - id: check-changes - run: | - # Get list of changed files - CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}) - - # Filter for src/ files - SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true) - - if [ -z "$SRC_FILES" ]; then - echo "No src/ files changed" - echo "should_run=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Count lines changed in src files - TOTAL_LINES=0 - for file in $SRC_FILES; do - if [ -f "$file" ]; then - # Count added lines (non-empty) - ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l) - # Count removed lines (non-empty) - REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l) - TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED)) - fi - done - - echo "Total lines changed in src/: $TOTAL_LINES" - - if [ $TOTAL_LINES -gt 3 ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - else - echo "should_run=false" >> "$GITHUB_OUTPUT" - fi - - - name: Check browser test coverage - id: check-coverage - if: steps.check-changes.outputs.should_run == 'true' - run: | - # Check if browser tests were updated - BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true) - - if [ -n "$BROWSER_TEST_CHANGES" ]; then - echo "has_browser_tests=true" >> "$GITHUB_OUTPUT" - else - echo "has_browser_tests=false" >> "$GITHUB_OUTPUT" - fi - - - name: Check for screen recording - id: check-recording - if: steps.check-changes.outputs.should_run == 'true' - env: - PR_BODY: ${{ github.event.pull_request.body }} - run: | - # Check PR body for screen recording - # Check for GitHub user attachments or YouTube links - if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then - echo "has_recording=true" >> "$GITHUB_OUTPUT" - else - echo "has_recording=false" >> "$GITHUB_OUTPUT" - fi - - - name: Final check and create results - id: final-check - if: always() - run: | - # Initialize results - WARNINGS_JSON="" - - # Only run checks if should_run is true - if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then - # Check browser test coverage - if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then - if [ -n "$WARNINGS_JSON" ]; then - WARNINGS_JSON="${WARNINGS_JSON}," - fi - WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}" - fi - - # Check screen recording - if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then - if [ -n "$WARNINGS_JSON" ]; then - WARNINGS_JSON="${WARNINGS_JSON}," - fi - WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}" - fi - fi - - # Create results JSON - if [ -n "$WARNINGS_JSON" ]; then - # Create JSON with warnings - cat > pr-check-results.json << EOF - { - "fails": [], - "warnings": [$WARNINGS_JSON], - "messages": [], - "markdowns": [] - } - EOF - echo "failed=false" >> "$GITHUB_OUTPUT" - else - # Create JSON with success - cat > pr-check-results.json << 'EOF' - { - "fails": [], - "warnings": [], - "messages": [], - "markdowns": [] - } - EOF - echo "failed=false" >> "$GITHUB_OUTPUT" - fi - - # Write PR metadata - echo "${{ github.event.pull_request.number }}" > pr-number.txt - echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt - - - name: Upload results - uses: actions/upload-artifact@v4 - if: always() - with: - name: pr-check-results-${{ github.run_id }} - path: | - pr-check-results.json - pr-number.txt - pr-sha.txt - retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml deleted file mode 100644 index 5ae8cf83a..000000000 --- a/.github/workflows/pr-comment.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: PR Comment -on: - workflow_run: - workflows: ["PR Checks"] - types: [completed] - -permissions: - pull-requests: write - issues: write - statuses: write - -jobs: - comment: - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - name: pr-check-results-${{ github.event.workflow_run.id }} - path: /tmp/pr-artifacts - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Post results - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - // Helper function to safely read files - function safeReadFile(filePath) { - try { - if (!fs.existsSync(filePath)) return null; - return fs.readFileSync(filePath, 'utf8').trim(); - } catch (e) { - console.error(`Error reading ${filePath}:`, e); - return null; - } - } - - // Read artifact files - const artifactDir = '/tmp/pr-artifacts'; - const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt')); - const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt')); - const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json')); - - // Validate PR number - if (!prNumber || isNaN(parseInt(prNumber))) { - throw new Error('Invalid or missing PR number'); - } - - // Parse and validate results - let results; - try { - results = JSON.parse(resultsJson || '{}'); - } catch (e) { - console.error('Failed to parse check results:', e); - - // Post error comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(prNumber), - body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}` - }); - return; - } - - // Format check messages - const messages = []; - - if (results.fails && results.fails.length > 0) { - messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n')); - } - - if (results.warnings && results.warnings.length > 0) { - messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n')); - } - - if (results.messages && results.messages.length > 0) { - messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n')); - } - - if (results.markdowns && results.markdowns.length > 0) { - messages.push(...results.markdowns.map(m => m.message)); - } - - // Find existing bot comment - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(prNumber) - }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('') - ); - - // Post comment if there are any messages - if (messages.length > 0) { - const body = messages.join('\n\n'); - const commentBody = `\n${body}`; - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(prNumber), - body: commentBody - }); - } - } else { - // No messages - delete existing comment if present - if (botComment) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id - }); - } - } - - // Set commit status based on failures - if (prSha) { - const hasFailures = results.fails && results.fails.length > 0; - const hasWarnings = results.warnings && results.warnings.length > 0; - await github.rest.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha: prSha, - state: hasFailures ? 'failure' : 'success', - context: 'pr-checks', - description: hasFailures - ? `${results.fails.length} check(s) failed` - : hasWarnings - ? `${results.warnings.length} warning(s)` - : 'All checks passed' - }); - } \ No newline at end of file diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index 39411ade3..45a84d23a 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -4,13 +4,18 @@ on: push: branches: [main, master, core/*, desktop/*] pull_request: - branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration] + branches-ignore: + [wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*] + +env: + DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p' jobs: setup: runs-on: ubuntu-latest outputs: cache-key: ${{ steps.cache-key.outputs.key }} + sanitized-branch: ${{ steps.branch-info.outputs.sanitized }} steps: - name: Checkout ComfyUI uses: actions/checkout@v4 @@ -36,6 +41,29 @@ jobs: with: node-version: lts/* + - name: Get current time + id: current-time + run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT + + - name: Comment PR - Tests Started + if: github.event_name == 'pull_request' + uses: edumserrano/find-create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: '' + comment-author: 'github-actions[bot]' + edit-mode: append + body: | + + + --- + + claude-loading-gif + [${{ steps.current-time.outputs.time }} UTC] Preparing browser tests across multiple browsers... + + --- + *This comment will be updated when tests complete* + - name: Build ComfyUI_frontend run: | npm ci @@ -46,6 +74,14 @@ jobs: id: cache-key run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT + - name: Generate sanitized branch name + id: branch-info + run: | + # Get branch name and sanitize it for Cloudflare branch names + BRANCH_NAME="${{ github.head_ref || github.ref_name }}" + SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') + echo "sanitized=${SANITIZED_BRANCH}" >> $GITHUB_OUTPUT + - name: Save cache uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 with: @@ -57,6 +93,10 @@ jobs: playwright-tests: needs: setup runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + contents: read strategy: fail-fast: false matrix: @@ -78,6 +118,32 @@ jobs: with: python-version: '3.10' + - name: Get current time + id: current-time + run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT + + - name: Set project name + id: project-name + run: | + if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then + echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT + else + echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT + fi + echo "branch=${{ needs.setup.outputs.sanitized-branch }}" >> $GITHUB_OUTPUT + + - name: Comment PR - Browser Test Started + if: github.event_name == 'pull_request' + uses: edumserrano/find-create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: '' + comment-author: 'github-actions[bot]' + edit-mode: append + body: | + claude-loading-gif + ${{ matrix.browser }}: Running tests... + - name: Install requirements run: | python -m pip install --upgrade pip @@ -96,13 +162,193 @@ jobs: run: npx playwright install chromium --with-deps working-directory: ComfyUI_frontend + - name: Install Wrangler + run: npm install -g wrangler + - name: Run Playwright tests (${{ matrix.browser }}) - run: npx playwright test --project=${{ matrix.browser }} + id: playwright + run: npx playwright test --project=${{ matrix.browser }} --reporter=html working-directory: ComfyUI_frontend - uses: actions/upload-artifact@v4 - if: always() + if: always() # note: use always() to allow results to be upload/report even tests failed. with: name: playwright-report-${{ matrix.browser }} path: ComfyUI_frontend/playwright-report/ retention-days: 30 + + - name: Deploy to Cloudflare Pages (${{ matrix.browser }}) + id: cloudflare-deploy + if: always() + run: | + # Retry logic for wrangler deploy (3 attempts) + RETRY_COUNT=0 + MAX_RETRIES=3 + SUCCESS=false + + while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..." + + if npx wrangler pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then + SUCCESS=true + echo "Deployment successful on attempt $RETRY_COUNT" + else + echo "Deployment failed on attempt $RETRY_COUNT" + if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then + echo "Retrying in 10 seconds..." + sleep 10 + fi + fi + done + + if [ $SUCCESS = false ]; then + echo "All deployment attempts failed" + exit 1 + fi + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Save deployment info for summary + if: always() + run: | + mkdir -p deployment-info + # Use step conclusion to determine test result + if [ "${{ steps.playwright.conclusion }}" = "success" ]; then + EXIT_CODE="0" + else + EXIT_CODE="1" + fi + DEPLOYMENT_URL="${{ steps.cloudflare-deploy.outputs.deployment-url || steps.cloudflare-deploy.outputs.url || format('https://{0}.{1}.pages.dev', steps.project-name.outputs.branch, steps.project-name.outputs.name) }}" + echo "${{ matrix.browser }}|${EXIT_CODE}|${DEPLOYMENT_URL}" > deployment-info/${{ matrix.browser }}.txt + + - name: Upload deployment info + if: always() + uses: actions/upload-artifact@v4 + with: + name: deployment-info-${{ matrix.browser }} + path: deployment-info/ + retention-days: 1 + + - name: Get completion time + id: completion-time + if: always() + run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT + + - name: Comment PR - Browser Test Complete + if: always() && github.event_name == 'pull_request' + uses: edumserrano/find-create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: '' + comment-author: 'github-actions[bot]' + edit-mode: append + body: | + ${{ steps.playwright.conclusion == 'success' && '✅' || '❌' }} **${{ matrix.browser }}**: ${{ steps.playwright.conclusion == 'success' && 'Tests passed!' || 'Tests failed!' }} [View Report](${{ steps.cloudflare-deploy.outputs.deployment-url || format('https://{0}.{1}.pages.dev', steps.project-name.outputs.branch, steps.project-name.outputs.name) }}) + + comment-summary: + needs: playwright-tests + runs-on: ubuntu-latest + if: always() && github.event_name == 'pull_request' + permissions: + pull-requests: write + steps: + - name: Download all deployment info + uses: actions/download-artifact@v4 + with: + pattern: deployment-info-* + merge-multiple: true + path: deployment-info + + - name: Get completion time + id: completion-time + run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT + + - name: Generate comment body + id: comment-body + run: | + echo "" > comment.md + echo "## 🎭 Playwright Test Results" >> comment.md + echo "" >> comment.md + + # Check if all tests passed + ALL_PASSED=true + for file in deployment-info/*.txt; do + if [ -f "$file" ]; then + browser=$(basename "$file" .txt) + info=$(cat "$file") + exit_code=$(echo "$info" | cut -d'|' -f2) + if [ "$exit_code" != "0" ]; then + ALL_PASSED=false + break + fi + fi + done + + if [ "$ALL_PASSED" = "true" ]; then + echo "✅ **All tests passed across all browsers!**" >> comment.md + else + echo "❌ **Some tests failed!**" >> comment.md + fi + + echo "" >> comment.md + echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md + echo "" >> comment.md + echo "### 📊 Test Reports by Browser" >> comment.md + + for file in deployment-info/*.txt; do + if [ -f "$file" ]; then + browser=$(basename "$file" .txt) + info=$(cat "$file") + exit_code=$(echo "$info" | cut -d'|' -f2) + url=$(echo "$info" | cut -d'|' -f3) + + if [ "$exit_code" = "0" ]; then + status="✅" + else + status="❌" + fi + + echo "- $status **$browser**: [View Report]($url)" >> comment.md + fi + done + + echo "" >> comment.md + echo "---" >> comment.md + if [ "$ALL_PASSED" = "true" ]; then + echo "🎉 Your tests are passing across all browsers!" >> comment.md + else + echo "⚠️ Please check the test reports for details on failures." >> comment.md + fi + + - name: Comment PR - Tests Complete + uses: edumserrano/find-create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: '' + comment-author: 'github-actions[bot]' + edit-mode: replace + body-path: comment.md + + - name: Check test results and fail if needed + run: | + # Check if all tests passed and fail the job if not + ALL_PASSED=true + for file in deployment-info/*.txt; do + if [ -f "$file" ]; then + info=$(cat "$file") + exit_code=$(echo "$info" | cut -d'|' -f2) + if [ "$exit_code" != "0" ]; then + ALL_PASSED=false + break + fi + fi + done + + if [ "$ALL_PASSED" = "false" ]; then + echo "❌ Tests failed in one or more browsers. Failing the CI job." + exit 1 + else + echo "✅ All tests passed across all browsers!" + fi diff --git a/.gitignore b/.gitignore index db5315411..8d19ceec5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ bun.lockb pnpm-lock.yaml yarn.lock -# ESLint cache +# Cache files .eslintcache +.prettiercache node_modules dist @@ -22,6 +23,7 @@ dist-ssr *.local # Claude configuration .claude/*.local.json +.claude/settings.json # Editor directories and files .vscode/* diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..cccae51c9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +src/types/comfyRegistryTypes.ts +src/types/generatedManagerTypes.ts \ No newline at end of file diff --git a/.storybook/README.md b/.storybook/README.md index f35753d27..902397471 100644 --- a/.storybook/README.md +++ b/.storybook/README.md @@ -93,6 +93,44 @@ export const WithVariant: Story = { ## Development Tips +## ComfyUI Storybook Guidelines + +### Scope – When to Create Stories +- **PrimeVue components**: + No need to create stories. Just refer to the official PrimeVue documentation. +- **Custom shared components (design system components)**: + Always create stories. These components are built in collaboration with designers, and Storybook serves as both documentation and a communication tool. +- **Container components (logic-heavy)**: + Do not create stories. Only the underlying pure UI components should be included in Storybook. + +### Maintenance Philosophy +- Stories are lightweight and generally stable. + Once created, they rarely need updates unless: + - The design changes + - New props (e.g. size, color variants) are introduced +- For existing usage patterns, simply copy real code examples into Storybook to create stories. + +### File Placement +- Keep `*.stories.ts` files at the **same level as the component** (similar to test files). +- This makes it easier to check usage examples without navigating to another directory. + +### Developer/Designer Workflow +- **UI vs Container**: Separate pure UI components from container components. + Only UI components should live in Storybook. +- **Communication Tool**: Storybook is not just about code quality—it enables designers and developers to see: + - Which props exist + - What cases are covered + - How variants (e.g. size, colors) look in isolation +- **Example**: + `PackActionButton.vue` wraps a PrimeVue button with additional logic. + → Only create a story for the base UI button, not for the wrapper. + +### Suggested Workflow +1. Use PrimeVue docs for standard components +2. Use Storybook for **shared/custom components** that define our design system +3. Keep story files alongside components +4. When in doubt, focus on components reused across the app or those that need to be showcased to designers + ### Best Practices 1. **Keep Stories Simple**: Each story should demonstrate one specific use case @@ -135,6 +173,7 @@ export const WithLongText: Story = { - **`main.ts`**: Core Storybook configuration and Vite integration - **`preview.ts`**: Global decorators, parameters, and Vue app setup - **`manager.ts`**: Storybook UI customization (if needed) +- **`preview-head.html`**: Injects custom HTML into the `` of every Storybook iframe (used for global styles, fonts, or fixes for iframe-specific issues) ## Chromatic Visual Testing @@ -170,4 +209,22 @@ This Storybook setup includes: - PrimeVue component library integration - Proper alias resolution for `@/` imports -For component-specific examples, see the NodePreview stories in `src/components/node/`. \ No newline at end of file +## Icon Usage in Storybook + +In this project, the `` syntax from unplugin-icons is not supported in Storybook. + +**Example:** + +```vue + + + +``` + +This approach ensures icons render correctly in Storybook and remain consistent with the rest of the app. + diff --git a/browser_tests/README.md b/browser_tests/README.md index 9c4d48f6b..ede6a303a 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -392,16 +392,6 @@ Option 2 - Generate local baselines for comparison: npx playwright test --update-snapshots ``` -### Getting Test Artifacts from GitHub Actions - -When tests fail in CI, you can download screenshots and traces: - -1. Go to the failed workflow run in GitHub Actions -2. Scroll to "Artifacts" section at the bottom -3. Download `playwright-report` or `test-results` -4. Extract and open the HTML report locally -5. View actual vs expected screenshots and execution traces - ### Creating New Screenshot Baselines For PRs from `Comfy-Org/ComfyUI_frontend` branches: @@ -412,6 +402,33 @@ For PRs from `Comfy-Org/ComfyUI_frontend` branches: > **Note:** Fork PRs cannot auto-commit screenshots. A maintainer will need to commit the screenshots manually for you (don't worry, they'll do it). +## Viewing Test Reports + +### Automated Test Deployment + +The project automatically deploys Playwright test reports to Cloudflare Pages for every PR and push to main branches. + +### Accessing Test Reports + +- **From PR comments**: Click the "View Report" links for each browser +- **Direct URLs**: Reports are available at `https://[branch].comfyui-playwright-[browser].pages.dev` (branch-specific deployments) +- **From GitHub Actions**: Download artifacts from failed runs + +### How It Works + +1. **Test execution**: All browser tests run in parallel across multiple browsers +2. **Report generation**: HTML reports are generated for each browser configuration +3. **Cloudflare deployment**: Each browser's report deploys to its own Cloudflare Pages project with branch isolation: + - `comfyui-playwright-chromium` (with branch-specific URLs) + - `comfyui-playwright-mobile-chrome` (with branch-specific URLs) + - `comfyui-playwright-chromium-2x` (2x scale, with branch-specific URLs) + - `comfyui-playwright-chromium-0-5x` (0.5x scale, with branch-specific URLs) + +4. **PR comments**: GitHub automatically updates PR comments with: + - ✅/❌ Test status for each browser + - Direct links to interactive test reports + - Real-time progress updates as tests complete + ## Resources - [Playwright UI Mode](https://playwright.dev/docs/test-ui-mode) - Interactive test debugging diff --git a/package-lock.json b/package-lock.json index 75311a1c8..5fe5dd683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.26.5", + "version": "1.26.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@comfyorg/comfyui-frontend", - "version": "1.26.5", + "version": "1.26.6", "license": "GPL-3.0-only", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -84,6 +84,7 @@ "identity-obj-proxy": "^3.0.0", "knip": "^5.62.0", "lint-staged": "^15.2.7", + "lucide-vue-next": "^0.540.0", "postcss": "^8.4.39", "prettier": "^3.3.2", "storybook": "^9.1.1", @@ -12224,6 +12225,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-vue-next": { + "version": "0.540.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.540.0.tgz", + "integrity": "sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index 9627875af..45d95b84e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.26.5", + "version": "1.26.6", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -14,8 +14,10 @@ "build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js", "zipdist": "node scripts/zipdist.js", "typecheck": "vue-tsc --noEmit", - "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'", - "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", + "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache", + "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", + "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'", + "format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", "test:browser": "npx playwright test", "test:unit": "vitest run tests-ui/tests", "test:component": "vitest run src/components/", @@ -25,7 +27,8 @@ "lint:fix": "eslint src --cache --fix", "lint:no-cache": "eslint src", "lint:fix:no-cache": "eslint src --fix", - "knip": "knip", + "knip": "knip --cache", + "knip:no-cache": "knip", "locale": "lobe-i18n locale", "collect-i18n": "playwright test --config=playwright.i18n.config.ts", "json-schema": "tsx scripts/generate-json-schema.ts", @@ -81,7 +84,8 @@ "vitest": "^2.0.0", "vue-tsc": "^2.1.10", "zip-dir": "^2.0.0", - "zod-to-json-schema": "^3.24.1" + "zod-to-json-schema": "^3.24.1", + "lucide-vue-next": "^0.540.0" }, "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/src/assets/icons/custom/workflow.svg b/src/assets/icons/custom/workflow.svg index 72f90c1a4..043d24e7b 100644 --- a/src/assets/icons/custom/workflow.svg +++ b/src/assets/icons/custom/workflow.svg @@ -1,7 +1,3 @@ - - - - - - + + diff --git a/src/components/button/IconButton.stories.ts b/src/components/button/IconButton.stories.ts new file mode 100644 index 000000000..a0194a240 --- /dev/null +++ b/src/components/button/IconButton.stories.ts @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next' + +import IconButton from './IconButton.vue' + +const meta: Meta = { + title: 'Components/Button/IconButton', + component: IconButton, + tags: ['autodocs'], + argTypes: { + size: { + control: { type: 'select' }, + options: ['sm', 'md'] + }, + type: { + control: { type: 'select' }, + options: ['primary', 'secondary', 'transparent'] + }, + onClick: { action: 'clicked' } + } +} + +export default meta +type Story = StoryObj + +export const Primary: Story = { + render: (args) => ({ + components: { IconButton, Trophy }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + type: 'primary', + size: 'md' + } +} + +export const Secondary: Story = { + render: (args) => ({ + components: { IconButton, Settings }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + type: 'secondary', + size: 'md' + } +} + +export const Transparent: Story = { + render: (args) => ({ + components: { IconButton, X }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + type: 'transparent', + size: 'md' + } +} + +export const Small: Story = { + render: (args) => ({ + components: { IconButton, Bell }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + type: 'secondary', + size: 'sm' + } +} + +export const AllVariants: Story = { + render: () => ({ + components: { IconButton, Trophy, Settings, X, Bell, Heart, Download }, + template: ` +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + + + + +
+
+ ` + }), + parameters: { + controls: { disable: true }, + actions: { disable: true } + } +} diff --git a/src/components/button/IconGroup.stories.ts b/src/components/button/IconGroup.stories.ts new file mode 100644 index 000000000..1eb0d6e0a --- /dev/null +++ b/src/components/button/IconGroup.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { Download, ExternalLink, Heart } from 'lucide-vue-next' + +import IconButton from './IconButton.vue' +import IconGroup from './IconGroup.vue' + +const meta: Meta = { + title: 'Components/Button/IconGroup', + component: IconGroup, + parameters: { + layout: 'centered' + } +} +export default meta + +type Story = StoryObj + +export const Basic: Story = { + render: () => ({ + components: { IconGroup, IconButton, Download, ExternalLink, Heart }, + template: ` + + + + + + + + + + + + ` + }) +} diff --git a/src/components/button/IconTextButton.stories.ts b/src/components/button/IconTextButton.stories.ts new file mode 100644 index 000000000..3c08c418a --- /dev/null +++ b/src/components/button/IconTextButton.stories.ts @@ -0,0 +1,221 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { + ChevronLeft, + ChevronRight, + Download, + Package, + Save, + Settings, + Trash2, + X +} from 'lucide-vue-next' + +import IconTextButton from './IconTextButton.vue' + +const meta: Meta = { + title: 'Components/Button/IconTextButton', + component: IconTextButton, + tags: ['autodocs'], + argTypes: { + label: { + control: 'text' + }, + size: { + control: { type: 'select' }, + options: ['sm', 'md'] + }, + type: { + control: { type: 'select' }, + options: ['primary', 'secondary', 'transparent'] + }, + iconPosition: { + control: { type: 'select' }, + options: ['left', 'right'] + }, + onClick: { action: 'clicked' } + } +} + +export default meta +type Story = StoryObj + +export const Primary: Story = { + render: (args) => ({ + components: { IconTextButton, Package }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + label: 'Deploy', + type: 'primary', + size: 'md' + } +} + +export const Secondary: Story = { + render: (args) => ({ + components: { IconTextButton, Settings }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + label: 'Settings', + type: 'secondary', + size: 'md' + } +} + +export const Transparent: Story = { + render: (args) => ({ + components: { IconTextButton, X }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + label: 'Cancel', + type: 'transparent', + size: 'md' + } +} + +export const WithIconRight: Story = { + render: (args) => ({ + components: { IconTextButton, ChevronRight }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + label: 'Next', + type: 'primary', + size: 'md', + iconPosition: 'right' + } +} + +export const Small: Story = { + render: (args) => ({ + components: { IconTextButton, Save }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + label: 'Save', + type: 'primary', + size: 'sm' + } +} + +export const AllVariants: Story = { + render: () => ({ + components: { + IconTextButton, + Download, + Settings, + Trash2, + ChevronRight, + ChevronLeft, + Save + }, + template: ` +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + + + + +
+
+ ` + }), + parameters: { + controls: { disable: true }, + actions: { disable: true } + } +} diff --git a/src/components/button/MoreButton.stories.ts b/src/components/button/MoreButton.stories.ts new file mode 100644 index 000000000..1a2171b09 --- /dev/null +++ b/src/components/button/MoreButton.stories.ts @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { Download, ScrollText } from 'lucide-vue-next' + +import IconTextButton from './IconTextButton.vue' +import MoreButton from './MoreButton.vue' + +const meta: Meta = { + title: 'Components/Button/MoreButton', + component: MoreButton, + parameters: { + layout: 'centered' + }, + argTypes: {} +} +export default meta + +type Story = StoryObj + +export const Basic: Story = { + render: () => ({ + components: { MoreButton, IconTextButton, Download, ScrollText }, + template: ` +
+ + + +
+ ` + }) +} diff --git a/src/components/button/TextButton.stories.ts b/src/components/button/TextButton.stories.ts new file mode 100644 index 000000000..c21dc280e --- /dev/null +++ b/src/components/button/TextButton.stories.ts @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import TextButton from './TextButton.vue' + +const meta: Meta = { + title: 'Components/Button/TextButton', + component: TextButton, + tags: ['autodocs'], + argTypes: { + label: { + control: 'text', + defaultValue: 'Click me' + }, + size: { + control: { type: 'select' }, + options: ['sm', 'md'], + defaultValue: 'md' + }, + type: { + control: { type: 'select' }, + options: ['primary', 'secondary', 'transparent'], + defaultValue: 'primary' + }, + onClick: { action: 'clicked' } + } +} + +export default meta +type Story = StoryObj + +export const Primary: Story = { + args: { + label: 'Primary Button', + type: 'primary', + size: 'md' + } +} + +export const Secondary: Story = { + args: { + label: 'Secondary Button', + type: 'secondary', + size: 'md' + } +} + +export const Transparent: Story = { + args: { + label: 'Transparent Button', + type: 'transparent', + size: 'md' + } +} + +export const Small: Story = { + args: { + label: 'Small Button', + type: 'primary', + size: 'sm' + } +} + +export const AllVariants: Story = { + render: () => ({ + components: { TextButton }, + template: ` +
+
+ + +
+
+ + +
+
+ + +
+
+ ` + }) +} diff --git a/src/components/card/Card.stories.ts b/src/components/card/Card.stories.ts new file mode 100644 index 000000000..0d8bf4385 --- /dev/null +++ b/src/components/card/Card.stories.ts @@ -0,0 +1,665 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { + Download, + Folder, + Heart, + Info, + MoreVertical, + Star, + Upload +} from 'lucide-vue-next' +import { ref } from 'vue' + +import IconButton from '../button/IconButton.vue' +import SquareChip from '../chip/SquareChip.vue' +import CardBottom from './CardBottom.vue' +import CardContainer from './CardContainer.vue' +import CardDescription from './CardDescription.vue' +import CardTitle from './CardTitle.vue' +import CardTop from './CardTop.vue' + +interface CardStoryArgs { + // CardContainer props + containerRatio: 'square' | 'portrait' | 'tallPortrait' + maxWidth: number + minWidth: number + + // CardTop props + topRatio: 'square' | 'landscape' + + // Content props + showTopLeft: boolean + showTopRight: boolean + showBottomLeft: boolean + showBottomRight: boolean + showTitle: boolean + showDescription: boolean + title: string + description: string + + // Visual props + backgroundColor: string + showImage: boolean + imageUrl: string + + // Tag props + tags: string[] + showFileSize: boolean + fileSize: string + showFileType: boolean + fileType: string +} + +const meta: Meta = { + title: 'Components/Card/Card', + argTypes: { + containerRatio: { + control: 'select', + options: ['square', 'portrait', 'tallPortrait'], + description: 'Card container aspect ratio' + }, + maxWidth: { + control: { type: 'range', min: 200, max: 600, step: 10 }, + description: 'Maximum width in pixels' + }, + minWidth: { + control: { type: 'range', min: 150, max: 400, step: 10 }, + description: 'Minimum width in pixels' + }, + topRatio: { + control: 'select', + options: ['square', 'landscape'], + description: 'Top section aspect ratio' + }, + showTopLeft: { + control: 'boolean', + description: 'Show top-left slot content' + }, + showTopRight: { + control: 'boolean', + description: 'Show top-right slot content' + }, + showBottomLeft: { + control: 'boolean', + description: 'Show bottom-left slot content' + }, + showBottomRight: { + control: 'boolean', + description: 'Show bottom-right slot content' + }, + showTitle: { + control: 'boolean', + description: 'Show card title' + }, + showDescription: { + control: 'boolean', + description: 'Show card description' + }, + title: { + control: 'text', + description: 'Card title text' + }, + description: { + control: 'text', + description: 'Card description text' + }, + backgroundColor: { + control: 'color', + description: 'Background color for card top' + }, + showImage: { + control: 'boolean', + description: 'Show image instead of color background' + }, + imageUrl: { + control: 'text', + description: 'Image URL for card top' + }, + tags: { + control: 'object', + description: 'Tags to display (array of strings)' + }, + showFileSize: { + control: 'boolean', + description: 'Show file size tag' + }, + fileSize: { + control: 'text', + description: 'File size text' + }, + showFileType: { + control: 'boolean', + description: 'Show file type tag' + }, + fileType: { + control: 'text', + description: 'File type text' + } + } +} + +export default meta +type Story = StoryObj + +const createCardTemplate = (args: CardStoryArgs) => ({ + components: { + CardContainer, + CardTop, + CardBottom, + CardTitle, + CardDescription, + IconButton, + SquareChip, + Info, + Folder, + Heart, + Download, + Star, + Upload, + MoreVertical + }, + setup() { + const favorited = ref(false) + const toggleFavorite = () => { + favorited.value = !favorited.value + } + + return { + args, + favorited, + toggleFavorite + } + }, + template: ` +
+ + + + + +
+ ` +}) + +export const Default: Story = { + render: (args: CardStoryArgs) => createCardTemplate(args), + args: { + containerRatio: 'portrait', + maxWidth: 300, + minWidth: 200, + topRatio: 'square', + showTopLeft: false, + showTopRight: true, + showBottomLeft: false, + showBottomRight: true, + showTitle: true, + showDescription: true, + title: 'Model Name', + description: + 'This is a detailed description of the model that can span multiple lines', + backgroundColor: '#3b82f6', + showImage: false, + imageUrl: '', + tags: ['LoRA', 'SDXL'], + showFileSize: true, + fileSize: '1.2 MB', + showFileType: true, + fileType: 'safetensors' + } +} + +export const SquareCard: Story = { + render: (args: CardStoryArgs) => createCardTemplate(args), + args: { + containerRatio: 'square', + maxWidth: 400, + minWidth: 250, + topRatio: 'landscape', + showTopLeft: false, + showTopRight: true, + showBottomLeft: false, + showBottomRight: true, + showTitle: true, + showDescription: true, + title: 'Workflow Bundle', + description: + 'Complete workflow for image generation with all necessary nodes', + backgroundColor: '#10b981', + showImage: false, + imageUrl: '', + tags: ['Workflow'], + showFileSize: true, + fileSize: '245 KB', + showFileType: true, + fileType: 'json' + } +} + +export const TallPortraitCard: Story = { + render: (args: CardStoryArgs) => createCardTemplate(args), + args: { + containerRatio: 'tallPortrait', + maxWidth: 280, + minWidth: 180, + topRatio: 'square', + showTopLeft: true, + showTopRight: true, + showBottomLeft: false, + showBottomRight: true, + showTitle: true, + showDescription: true, + title: 'Premium Model', + description: + 'High-quality photorealistic model trained on professional photography', + backgroundColor: '#8b5cf6', + showImage: false, + imageUrl: '', + tags: ['SD 1.5', 'Checkpoint'], + showFileSize: true, + fileSize: '2.1 GB', + showFileType: true, + fileType: 'ckpt' + } +} + +export const ImageCard: Story = { + render: (args: CardStoryArgs) => createCardTemplate(args), + args: { + containerRatio: 'portrait', + maxWidth: 350, + minWidth: 220, + topRatio: 'square', + showTopLeft: false, + showTopRight: true, + showBottomLeft: false, + showBottomRight: true, + showTitle: true, + showDescription: true, + title: 'Generated Image', + description: 'Created with DreamShaper XL', + backgroundColor: '#3b82f6', + showImage: true, + imageUrl: 'https://picsum.photos/400/400', + tags: ['Output'], + showFileSize: true, + fileSize: '856 KB', + showFileType: true, + fileType: 'png' + } +} + +export const MinimalCard: Story = { + render: (args: CardStoryArgs) => createCardTemplate(args), + args: { + containerRatio: 'square', + maxWidth: 300, + minWidth: 200, + topRatio: 'landscape', + showTopLeft: false, + showTopRight: false, + showBottomLeft: false, + showBottomRight: false, + showTitle: true, + showDescription: false, + title: 'Simple Card', + description: '', + backgroundColor: '#64748b', + showImage: false, + imageUrl: '', + tags: [], + showFileSize: false, + fileSize: '', + showFileType: false, + fileType: '' + } +} + +export const FullFeaturedCard: Story = { + render: (args: CardStoryArgs) => createCardTemplate(args), + args: { + containerRatio: 'tallPortrait', + maxWidth: 320, + minWidth: 240, + topRatio: 'square', + showTopLeft: true, + showTopRight: true, + showBottomLeft: true, + showBottomRight: true, + showTitle: true, + showDescription: true, + title: 'Ultimate Model Pack', + description: + 'Complete collection with checkpoints, LoRAs, embeddings, and VAE models for professional use', + backgroundColor: '#ef4444', + showImage: false, + imageUrl: '', + tags: ['Bundle', 'Premium', 'SDXL'], + showFileSize: true, + fileSize: '5.4 GB', + showFileType: true, + fileType: 'pack' + } +} + +export const GridOfCards: Story = { + render: () => ({ + components: { + CardContainer, + CardTop, + CardBottom, + CardTitle, + CardDescription, + IconButton, + SquareChip, + Info, + Folder, + Heart, + Download + }, + setup() { + const cards = ref([ + { + id: 1, + title: 'Realistic Vision', + description: 'Photorealistic model for portraits', + color: 'from-blue-400 to-blue-600', + ratio: 'portrait' as const, + tags: ['SD 1.5'], + size: '2.1 GB' + }, + { + id: 2, + title: 'DreamShaper XL', + description: 'Artistic style model with enhanced details', + color: 'from-purple-400 to-pink-600', + ratio: 'portrait' as const, + tags: ['SDXL'], + size: '6.5 GB' + }, + { + id: 3, + title: 'Anime LoRA', + description: 'Character style LoRA', + color: 'from-green-400 to-teal-600', + ratio: 'portrait' as const, + tags: ['LoRA'], + size: '144 MB' + }, + { + id: 4, + title: 'VAE Model', + description: 'Enhanced color VAE', + color: 'from-orange-400 to-red-600', + ratio: 'portrait' as const, + tags: ['VAE'], + size: '335 MB' + }, + { + id: 5, + title: 'Workflow Bundle', + description: 'Complete workflow setup', + color: 'from-indigo-400 to-blue-600', + ratio: 'portrait' as const, + tags: ['Workflow'], + size: '45 KB' + }, + { + id: 6, + title: 'Embedding Pack', + description: 'Negative embeddings collection', + color: 'from-yellow-400 to-orange-600', + ratio: 'portrait' as const, + tags: ['Embedding'], + size: '2.3 MB' + } + ]) + + return { cards } + }, + template: ` +
+

Model Gallery

+
+ + + + + +
+
+ ` + }) +} + +export const ResponsiveGrid: Story = { + render: () => ({ + components: { + CardContainer, + CardTop, + CardBottom, + CardTitle, + CardDescription, + SquareChip + }, + setup() { + const generateCards = ( + count: number, + ratio: 'square' | 'portrait' | 'tallPortrait' + ) => { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + title: `Model ${i + 1}`, + description: `Description for model ${i + 1}`, + ratio, + color: `hsl(${(i * 60) % 360}, 70%, 60%)` + })) + } + + const squareCards = ref(generateCards(4, 'square')) + const portraitCards = ref(generateCards(6, 'portrait')) + const tallCards = ref(generateCards(5, 'tallPortrait')) + + return { + squareCards, + portraitCards, + tallCards + } + }, + template: ` +
+
+

Square Cards (1:1)

+
+ + + + +
+
+ +
+

Portrait Cards (2:3)

+
+ + + + +
+
+ +
+

Tall Portrait Cards (2:4)

+
+ + + + +
+
+
+ ` + }), + parameters: { + controls: { disable: true }, + actions: { disable: true } + } +} diff --git a/src/components/card/CardContainer.vue b/src/components/card/CardContainer.vue index 597429a9e..ebcac78e8 100644 --- a/src/components/card/CardContainer.vue +++ b/src/components/card/CardContainer.vue @@ -13,8 +13,8 @@ const { maxWidth, minWidth } = defineProps<{ - maxWidth: number - minWidth: number + maxWidth?: number + minWidth?: number ratio?: 'square' | 'portrait' | 'tallPortrait' }>() @@ -31,8 +31,12 @@ const containerClasses = computed(() => { return `${baseClasses} ${ratioClasses[ratio]}` }) -const containerStyle = computed(() => ({ - maxWidth: `${maxWidth}px`, - minWidth: `${minWidth}px` -})) +const containerStyle = computed(() => + maxWidth || minWidth + ? { + maxWidth: `${maxWidth}px`, + minWidth: `${minWidth}px` + } + : {} +) diff --git a/src/components/chip/SquareChip.stories.ts b/src/components/chip/SquareChip.stories.ts new file mode 100644 index 000000000..6ae12b1e9 --- /dev/null +++ b/src/components/chip/SquareChip.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import SquareChip from './SquareChip.vue' + +const meta: Meta = { + title: 'Components/SquareChip', + component: SquareChip, + tags: ['autodocs'], + argTypes: { + label: { + control: 'text', + defaultValue: 'Tag' + } + } +} + +export default meta +type Story = StoryObj + +export const TagList: Story = { + render: () => ({ + components: { SquareChip }, + template: ` +
+ + + + + + + + +
+ ` + }) +} diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index d4cf408fc..bbbeadef9 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -12,7 +12,6 @@ - @@ -35,7 +34,6 @@ import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue' import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue' import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue' import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue' -import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue' import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue' import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue' import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue' diff --git a/src/components/graph/selectionToolbox/EditModelButton.vue b/src/components/graph/selectionToolbox/EditModelButton.vue deleted file mode 100644 index d9f515ddd..000000000 --- a/src/components/graph/selectionToolbox/EditModelButton.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/input/MultiSelect.stories.ts new file mode 100644 index 000000000..fa8d7668c --- /dev/null +++ b/src/components/input/MultiSelect.stories.ts @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import MultiSelect from './MultiSelect.vue' + +const meta: Meta = { + title: 'Components/Input/MultiSelect', + component: MultiSelect, + tags: ['autodocs'], + argTypes: { + label: { + control: 'text' + }, + options: { + control: 'object' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selected = ref([]) + const options = [ + { name: 'Vue', value: 'vue' }, + { name: 'React', value: 'react' }, + { name: 'Angular', value: 'angular' }, + { name: 'Svelte', value: 'svelte' } + ] + return { selected, options, args } + }, + template: ` +
+ +
+

Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}

+
+
+ ` + }) +} + +export const WithPreselectedValues: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const options = [ + { name: 'JavaScript', value: 'js' }, + { name: 'TypeScript', value: 'ts' }, + { name: 'Python', value: 'python' }, + { name: 'Go', value: 'go' }, + { name: 'Rust', value: 'rust' } + ] + const selected = ref([options[0], options[1]]) + return { selected, options } + }, + template: ` +
+ +
+

Selected: {{ selected.map(s => s.name).join(', ') }}

+
+
+ ` + }) +} + +export const MultipleSelectors: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const frameworkOptions = ref([ + { name: 'Vue', value: 'vue' }, + { name: 'React', value: 'react' }, + { name: 'Angular', value: 'angular' }, + { name: 'Svelte', value: 'svelte' } + ]) + + const projectOptions = ref([ + { name: 'Project A', value: 'proj-a' }, + { name: 'Project B', value: 'proj-b' }, + { name: 'Project C', value: 'proj-c' }, + { name: 'Project D', value: 'proj-d' } + ]) + + const tagOptions = ref([ + { name: 'Frontend', value: 'frontend' }, + { name: 'Backend', value: 'backend' }, + { name: 'Database', value: 'database' }, + { name: 'DevOps', value: 'devops' }, + { name: 'Testing', value: 'testing' } + ]) + + const selectedFrameworks = ref([]) + const selectedProjects = ref([]) + const selectedTags = ref([]) + + return { + frameworkOptions, + projectOptions, + tagOptions, + selectedFrameworks, + selectedProjects, + selectedTags + } + }, + template: ` +
+
+ + + +
+ +
+

Current Selection:

+
+

Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}

+

Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}

+

Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}

+
+
+
+ ` + }) +} diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index 2dd01dcb5..d1d02a4f1 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -9,6 +9,41 @@ :max-selected-labels="0" :pt="pt" > + + - +
() +import SearchBox from '@/components/input/SearchBox.vue' -const selectedItems = defineModel<{ name: string; value: string }[]>({ +import TextButton from '../button/TextButton.vue' + +type Option = { name: string; value: string } + +interface Props { + /** Input label shown on the trigger button */ + label?: string + /** Static options for the multiselect (when not using async search) */ + options: Option[] + /** Show search box in the panel header */ + hasSearchBox?: boolean + /** Show selected count text in the panel header */ + showSelectedCount?: boolean + /** Show "Clear all" action in the panel header */ + hasClearButton?: boolean + /** Placeholder for the search input */ + searchPlaceholder?: string +} +const { + label, + options, + hasSearchBox = false, + showSelectedCount = false, + hasClearButton = false, + searchPlaceholder = 'Search...' +} = defineProps() + +const selectedItems = defineModel({ required: true }) - +const searchQuery = defineModel('searchQuery') const selectedCount = computed(() => selectedItems.value.length) -/** - * Pure unstyled mode using only the PrimeVue PT API. - * All PrimeVue built-in checkboxes/headers are hidden via PT (no :deep hacks). - * Visual output matches the previous version exactly. - */ const pt = computed(() => ({ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ class: [ @@ -97,19 +151,19 @@ const pt = computed(() => ({ dropdown: { class: 'flex shrink-0 cursor-pointer items-center justify-center px-3' }, - header: { class: 'hidden' }, - + header: () => ({ + class: + hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden' + }), // Overlay & list visuals unchanged overlay: - 'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100', + 'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700', list: { class: 'flex flex-col gap-1 p-0 list-none border-none text-xs' }, - // Option row hover tone identical option: 'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50', - // Hide built-in checkboxes entirely via PT (no :deep) pcHeaderCheckbox: { root: { class: 'hidden' }, diff --git a/src/components/input/SearchBox.stories.ts b/src/components/input/SearchBox.stories.ts new file mode 100644 index 000000000..da2ff7458 --- /dev/null +++ b/src/components/input/SearchBox.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import SearchBox from './SearchBox.vue' + +const meta: Meta = { + title: 'Components/Input/SearchBox', + component: SearchBox, + tags: ['autodocs'], + argTypes: { + placeHolder: { + control: 'text' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { SearchBox }, + setup() { + const searchText = ref('') + return { searchText, args } + }, + template: ` +
+ +
+ ` + }) +} diff --git a/src/components/input/SearchBox.vue b/src/components/input/SearchBox.vue index 08075b18b..462668a12 100644 --- a/src/components/input/SearchBox.vue +++ b/src/components/input/SearchBox.vue @@ -1,8 +1,6 @@