mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat/model-total-credits
This commit is contained in:
commit
9aee14f4f8
|
|
@ -13,12 +13,28 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check Docker Compose inputs
|
||||||
|
id: docker-compose-changes
|
||||||
|
uses: tj-actions/changed-files@v46
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
docker/generate_docker_compose
|
||||||
|
docker/.env.example
|
||||||
|
docker/docker-compose-template.yaml
|
||||||
|
docker/docker-compose.yaml
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v6
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
|
||||||
|
- name: Generate Docker Compose
|
||||||
|
if: steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
cd docker
|
||||||
|
./generate_docker_compose
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
cd api
|
cd api
|
||||||
uv sync --dev
|
uv sync --dev
|
||||||
|
|
|
||||||
|
|
@ -108,36 +108,6 @@ jobs:
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
run: pnpm run type-check:tsgo
|
run: pnpm run type-check:tsgo
|
||||||
|
|
||||||
docker-compose-template:
|
|
||||||
name: Docker Compose Template
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check changed files
|
|
||||||
id: changed-files
|
|
||||||
uses: tj-actions/changed-files@v46
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
docker/generate_docker_compose
|
|
||||||
docker/.env.example
|
|
||||||
docker/docker-compose-template.yaml
|
|
||||||
docker/docker-compose.yaml
|
|
||||||
|
|
||||||
- name: Generate Docker Compose
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
run: |
|
|
||||||
cd docker
|
|
||||||
./generate_docker_compose
|
|
||||||
|
|
||||||
- name: Check for changes
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
run: git diff --exit-code
|
|
||||||
|
|
||||||
superlinter:
|
superlinter:
|
||||||
name: SuperLinter
|
name: SuperLinter
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import os
|
||||||
|
from email.message import Message
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
|
||||||
|
HTML_EXTENSIONS = frozenset({"html", "htm"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_mime_type(mime_type: str | None) -> str:
|
||||||
|
if not mime_type:
|
||||||
|
return ""
|
||||||
|
message = Message()
|
||||||
|
message["Content-Type"] = mime_type
|
||||||
|
return message.get_content_type().strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_html_extension(extension: str | None) -> bool:
|
||||||
|
if not extension:
|
||||||
|
return False
|
||||||
|
return extension.lstrip(".").lower() in HTML_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def is_html_content(mime_type: str | None, filename: str | None, extension: str | None = None) -> bool:
|
||||||
|
normalized_mime_type = _normalize_mime_type(mime_type)
|
||||||
|
if normalized_mime_type in HTML_MIME_TYPES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if _is_html_extension(extension):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
return _is_html_extension(os.path.splitext(filename)[1])
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_download_for_html(
|
||||||
|
response: Response,
|
||||||
|
*,
|
||||||
|
mime_type: str | None,
|
||||||
|
filename: str | None,
|
||||||
|
extension: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if not is_html_content(mime_type, filename, extension):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
encoded_filename = quote(filename)
|
||||||
|
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||||
|
else:
|
||||||
|
response.headers["Content-Disposition"] = "attachment"
|
||||||
|
|
||||||
|
response.headers["Content-Type"] = "application/octet-stream"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
return True
|
||||||
|
|
@ -7,6 +7,7 @@ from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
import services
|
import services
|
||||||
from controllers.common.errors import UnsupportedFileTypeError
|
from controllers.common.errors import UnsupportedFileTypeError
|
||||||
|
from controllers.common.file_response import enforce_download_for_html
|
||||||
from controllers.files import files_ns
|
from controllers.files import files_ns
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from services.account_service import TenantService
|
from services.account_service import TenantService
|
||||||
|
|
@ -138,6 +139,13 @@ class FilePreviewApi(Resource):
|
||||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||||
response.headers["Content-Type"] = "application/octet-stream"
|
response.headers["Content-Type"] = "application/octet-stream"
|
||||||
|
|
||||||
|
enforce_download_for_html(
|
||||||
|
response,
|
||||||
|
mime_type=upload_file.mime_type,
|
||||||
|
filename=upload_file.name,
|
||||||
|
extension=upload_file.extension,
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
|
||||||
from werkzeug.exceptions import Forbidden, NotFound
|
from werkzeug.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
from controllers.common.errors import UnsupportedFileTypeError
|
from controllers.common.errors import UnsupportedFileTypeError
|
||||||
|
from controllers.common.file_response import enforce_download_for_html
|
||||||
from controllers.files import files_ns
|
from controllers.files import files_ns
|
||||||
from core.tools.signature import verify_tool_file_signature
|
from core.tools.signature import verify_tool_file_signature
|
||||||
from core.tools.tool_file_manager import ToolFileManager
|
from core.tools.tool_file_manager import ToolFileManager
|
||||||
|
|
@ -78,4 +79,11 @@ class ToolFileApi(Resource):
|
||||||
encoded_filename = quote(tool_file.name)
|
encoded_filename = quote(tool_file.name)
|
||||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||||
|
|
||||||
|
enforce_download_for_html(
|
||||||
|
response,
|
||||||
|
mime_type=tool_file.mimetype,
|
||||||
|
filename=tool_file.name,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from flask import Response, request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from controllers.common.file_response import enforce_download_for_html
|
||||||
from controllers.common.schema import register_schema_model
|
from controllers.common.schema import register_schema_model
|
||||||
from controllers.service_api import service_api_ns
|
from controllers.service_api import service_api_ns
|
||||||
from controllers.service_api.app.error import (
|
from controllers.service_api.app.error import (
|
||||||
|
|
@ -183,6 +184,13 @@ class FilePreviewApi(Resource):
|
||||||
# Override content-type for downloads to force download
|
# Override content-type for downloads to force download
|
||||||
response.headers["Content-Type"] = "application/octet-stream"
|
response.headers["Content-Type"] = "application/octet-stream"
|
||||||
|
|
||||||
|
enforce_download_for_html(
|
||||||
|
response,
|
||||||
|
mime_type=upload_file.mime_type,
|
||||||
|
filename=upload_file.name,
|
||||||
|
extension=upload_file.extension,
|
||||||
|
)
|
||||||
|
|
||||||
# Add caching headers for performance
|
# Add caching headers for performance
|
||||||
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour
|
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,11 +153,11 @@ class ToolInvokeMessage(BaseModel):
|
||||||
@classmethod
|
@classmethod
|
||||||
def transform_variable_value(cls, values):
|
def transform_variable_value(cls, values):
|
||||||
"""
|
"""
|
||||||
Only basic types and lists are allowed.
|
Only basic types, lists, and None are allowed.
|
||||||
"""
|
"""
|
||||||
value = values.get("variable_value")
|
value = values.get("variable_value")
|
||||||
if not isinstance(value, dict | list | str | int | float | bool):
|
if value is not None and not isinstance(value, dict | list | str | int | float | bool):
|
||||||
raise ValueError("Only basic types and lists are allowed.")
|
raise ValueError("Only basic types, lists, and None are allowed.")
|
||||||
|
|
||||||
# if stream is true, the value must be a string
|
# if stream is true, the value must be a string
|
||||||
if values.get("stream"):
|
if values.get("stream"):
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||||
|
|
||||||
# handle invoke result
|
# handle invoke result
|
||||||
|
|
||||||
text = invoke_result.message.content or ""
|
text = invoke_result.message.get_text_content()
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise InvalidTextContentTypeError(f"Invalid text content type: {type(text)}. Expected str.")
|
raise InvalidTextContentTypeError(f"Invalid text content type: {type(text)}. Expected str.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
from controllers.common.file_response import enforce_download_for_html, is_html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileResponseHelpers:
|
||||||
|
def test_is_html_content_detects_mime_type(self):
|
||||||
|
mime_type = "text/html; charset=UTF-8"
|
||||||
|
|
||||||
|
result = is_html_content(mime_type, filename="file.txt", extension="txt")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_is_html_content_detects_extension(self):
|
||||||
|
result = is_html_content("text/plain", filename="report.html", extension=None)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_enforce_download_for_html_sets_headers(self):
|
||||||
|
response = Response("payload", mimetype="text/html")
|
||||||
|
|
||||||
|
updated = enforce_download_for_html(
|
||||||
|
response,
|
||||||
|
mime_type="text/html",
|
||||||
|
filename="unsafe.html",
|
||||||
|
extension="html",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated is True
|
||||||
|
assert "attachment" in response.headers["Content-Disposition"]
|
||||||
|
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||||
|
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||||
|
|
||||||
|
def test_enforce_download_for_html_no_change_for_non_html(self):
|
||||||
|
response = Response("payload", mimetype="text/plain")
|
||||||
|
|
||||||
|
updated = enforce_download_for_html(
|
||||||
|
response,
|
||||||
|
mime_type="text/plain",
|
||||||
|
filename="notes.txt",
|
||||||
|
extension="txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated is False
|
||||||
|
assert "Content-Disposition" not in response.headers
|
||||||
|
assert "X-Content-Type-Options" not in response.headers
|
||||||
|
|
@ -41,6 +41,7 @@ class TestFilePreviewApi:
|
||||||
upload_file = Mock(spec=UploadFile)
|
upload_file = Mock(spec=UploadFile)
|
||||||
upload_file.id = str(uuid.uuid4())
|
upload_file.id = str(uuid.uuid4())
|
||||||
upload_file.name = "test_file.jpg"
|
upload_file.name = "test_file.jpg"
|
||||||
|
upload_file.extension = "jpg"
|
||||||
upload_file.mime_type = "image/jpeg"
|
upload_file.mime_type = "image/jpeg"
|
||||||
upload_file.size = 1024
|
upload_file.size = 1024
|
||||||
upload_file.key = "storage/key/test_file.jpg"
|
upload_file.key = "storage/key/test_file.jpg"
|
||||||
|
|
@ -210,6 +211,19 @@ class TestFilePreviewApi:
|
||||||
assert mock_upload_file.name in response.headers["Content-Disposition"]
|
assert mock_upload_file.name in response.headers["Content-Disposition"]
|
||||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||||
|
|
||||||
|
def test_build_file_response_html_forces_attachment(self, file_preview_api, mock_upload_file):
|
||||||
|
"""Test HTML files are forced to download"""
|
||||||
|
mock_generator = Mock()
|
||||||
|
mock_upload_file.mime_type = "text/html"
|
||||||
|
mock_upload_file.name = "unsafe.html"
|
||||||
|
mock_upload_file.extension = "html"
|
||||||
|
|
||||||
|
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||||
|
|
||||||
|
assert "attachment" in response.headers["Content-Disposition"]
|
||||||
|
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||||
|
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||||
|
|
||||||
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
|
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
|
||||||
"""Test file response building for audio/video files"""
|
"""Test file response building for audio/video files"""
|
||||||
mock_generator = Mock()
|
mock_generator = Mock()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,360 @@
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import Badge, { BadgeState, BadgeVariants } from './index'
|
||||||
|
|
||||||
|
describe('Badge', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render as a div element with badge class', () => {
|
||||||
|
render(<Badge>Test Badge</Badge>)
|
||||||
|
|
||||||
|
const badge = screen.getByText('Test Badge')
|
||||||
|
expect(badge).toHaveClass('badge')
|
||||||
|
expect(badge.tagName).toBe('DIV')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ children: undefined, label: 'no children' },
|
||||||
|
{ children: '', label: 'empty string' },
|
||||||
|
])('should render correctly when provided $label', ({ children }) => {
|
||||||
|
const { container } = render(<Badge>{children}</Badge>)
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveClass('badge')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render React Node children correctly', () => {
|
||||||
|
render(
|
||||||
|
<Badge data-testid="badge-with-icon">
|
||||||
|
<span data-testid="custom-icon">🔔</span>
|
||||||
|
</Badge>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('badge-with-icon')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('size prop', () => {
|
||||||
|
it.each([
|
||||||
|
{ size: undefined, label: 'medium (default)' },
|
||||||
|
{ size: 's', label: 'small' },
|
||||||
|
{ size: 'm', label: 'medium' },
|
||||||
|
{ size: 'l', label: 'large' },
|
||||||
|
] as const)('should render with $label size', ({ size }) => {
|
||||||
|
render(<Badge size={size}>Test</Badge>)
|
||||||
|
|
||||||
|
const expectedSize = size || 'm'
|
||||||
|
expect(screen.getByText('Test')).toHaveClass('badge', `badge-${expectedSize}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('state prop', () => {
|
||||||
|
it.each([
|
||||||
|
{ state: BadgeState.Warning, label: 'warning', expectedClass: 'badge-warning' },
|
||||||
|
{ state: BadgeState.Accent, label: 'accent', expectedClass: 'badge-accent' },
|
||||||
|
])('should render with $label state', ({ state, expectedClass }) => {
|
||||||
|
render(<Badge state={state}>State Test</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText('State Test')).toHaveClass(expectedClass)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ state: undefined, label: 'default (undefined)' },
|
||||||
|
{ state: BadgeState.Default, label: 'default (explicit)' },
|
||||||
|
])('should use default styles when state is $label', ({ state }) => {
|
||||||
|
render(<Badge state={state}>State Test</Badge>)
|
||||||
|
|
||||||
|
const badge = screen.getByText('State Test')
|
||||||
|
expect(badge).not.toHaveClass('badge-warning', 'badge-accent')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('iconOnly prop', () => {
|
||||||
|
it.each([
|
||||||
|
{ size: 's', iconOnly: false, label: 'small with text' },
|
||||||
|
{ size: 's', iconOnly: true, label: 'small icon-only' },
|
||||||
|
{ size: 'm', iconOnly: false, label: 'medium with text' },
|
||||||
|
{ size: 'm', iconOnly: true, label: 'medium icon-only' },
|
||||||
|
{ size: 'l', iconOnly: false, label: 'large with text' },
|
||||||
|
{ size: 'l', iconOnly: true, label: 'large icon-only' },
|
||||||
|
] as const)('should render correctly for $label', ({ size, iconOnly }) => {
|
||||||
|
const { container } = render(<Badge size={size} iconOnly={iconOnly}>🔔</Badge>)
|
||||||
|
const badge = screen.getByText('🔔')
|
||||||
|
|
||||||
|
// Verify badge renders with correct size
|
||||||
|
expect(badge).toHaveClass('badge', `badge-${size}`)
|
||||||
|
|
||||||
|
// Verify the badge is in the DOM and contains the content
|
||||||
|
expect(badge).toBeInTheDocument()
|
||||||
|
expect(container.firstChild).toBe(badge)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply icon-only padding when iconOnly is true', () => {
|
||||||
|
render(<Badge iconOnly>🔔</Badge>)
|
||||||
|
|
||||||
|
// When iconOnly is true, the badge should have uniform padding (all sides equal)
|
||||||
|
const badge = screen.getByText('🔔')
|
||||||
|
expect(badge).toHaveClass('p-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply asymmetric padding when iconOnly is false', () => {
|
||||||
|
render(<Badge iconOnly={false}>Badge</Badge>)
|
||||||
|
|
||||||
|
// When iconOnly is false, the badge should have different horizontal and vertical padding
|
||||||
|
const badge = screen.getByText('Badge')
|
||||||
|
expect(badge).toHaveClass('px-[5px]', 'py-[2px]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('uppercase prop', () => {
|
||||||
|
it.each([
|
||||||
|
{ uppercase: undefined, label: 'default (undefined)', expected: 'system-2xs-medium' },
|
||||||
|
{ uppercase: false, label: 'explicitly false', expected: 'system-2xs-medium' },
|
||||||
|
{ uppercase: true, label: 'true', expected: 'system-2xs-medium-uppercase' },
|
||||||
|
])('should apply $expected class when uppercase is $label', ({ uppercase, expected }) => {
|
||||||
|
render(<Badge uppercase={uppercase}>Text</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText('Text')).toHaveClass(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('styleCss prop', () => {
|
||||||
|
it('should apply custom inline styles correctly', () => {
|
||||||
|
const customStyles = {
|
||||||
|
backgroundColor: 'rgb(0, 0, 255)',
|
||||||
|
color: 'rgb(255, 255, 255)',
|
||||||
|
padding: '10px',
|
||||||
|
}
|
||||||
|
render(<Badge styleCss={customStyles}>Styled Badge</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText('Styled Badge')).toHaveStyle(customStyles)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply inline styles without overriding core classes', () => {
|
||||||
|
render(<Badge styleCss={{ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' }}>Custom</Badge>)
|
||||||
|
|
||||||
|
const badge = screen.getByText('Custom')
|
||||||
|
expect(badge).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' })
|
||||||
|
expect(badge).toHaveClass('badge')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('className prop', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
props: { className: 'custom-badge' },
|
||||||
|
expected: ['badge', 'custom-badge'],
|
||||||
|
label: 'single custom class',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: { className: 'custom-class another-class', size: 'l' as const },
|
||||||
|
expected: ['badge', 'badge-l', 'custom-class', 'another-class'],
|
||||||
|
label: 'multiple classes with size variant',
|
||||||
|
},
|
||||||
|
])('should merge $label with default classes', ({ props, expected }) => {
|
||||||
|
render(<Badge {...props}>Test</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test')).toHaveClass(...expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HTML attributes passthrough', () => {
|
||||||
|
it.each([
|
||||||
|
{ attr: 'data-testid', value: 'custom-badge-id', label: 'data attribute' },
|
||||||
|
{ attr: 'id', value: 'unique-badge', label: 'id attribute' },
|
||||||
|
{ attr: 'aria-label', value: 'Notification badge', label: 'aria-label' },
|
||||||
|
{ attr: 'title', value: 'Hover tooltip', label: 'title attribute' },
|
||||||
|
{ attr: 'role', value: 'status', label: 'ARIA role' },
|
||||||
|
])('should pass through $label correctly', ({ attr, value }) => {
|
||||||
|
render(<Badge {...{ [attr]: value }}>Test</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test')).toHaveAttribute(attr, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support multiple HTML attributes simultaneously', () => {
|
||||||
|
render(
|
||||||
|
<Badge
|
||||||
|
data-testid="multi-attr-badge"
|
||||||
|
id="badge-123"
|
||||||
|
aria-label="Status indicator"
|
||||||
|
title="Current status"
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</Badge>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const badge = screen.getByTestId('multi-attr-badge')
|
||||||
|
expect(badge).toHaveAttribute('id', 'badge-123')
|
||||||
|
expect(badge).toHaveAttribute('aria-label', 'Status indicator')
|
||||||
|
expect(badge).toHaveAttribute('title', 'Current status')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Event handlers', () => {
|
||||||
|
it.each([
|
||||||
|
{ handler: 'onClick', trigger: fireEvent.click, label: 'click' },
|
||||||
|
{ handler: 'onMouseEnter', trigger: fireEvent.mouseEnter, label: 'mouse enter' },
|
||||||
|
{ handler: 'onMouseLeave', trigger: fireEvent.mouseLeave, label: 'mouse leave' },
|
||||||
|
])('should trigger $handler when $label occurs', ({ handler, trigger }) => {
|
||||||
|
const mockHandler = vi.fn()
|
||||||
|
render(<Badge {...{ [handler]: mockHandler }}>Badge</Badge>)
|
||||||
|
|
||||||
|
trigger(screen.getByText('Badge'))
|
||||||
|
|
||||||
|
expect(mockHandler).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle user interaction flow with multiple events', () => {
|
||||||
|
const handlers = {
|
||||||
|
onClick: vi.fn(),
|
||||||
|
onMouseEnter: vi.fn(),
|
||||||
|
onMouseLeave: vi.fn(),
|
||||||
|
}
|
||||||
|
render(<Badge {...handlers}>Interactive</Badge>)
|
||||||
|
|
||||||
|
const badge = screen.getByText('Interactive')
|
||||||
|
fireEvent.mouseEnter(badge)
|
||||||
|
fireEvent.click(badge)
|
||||||
|
fireEvent.mouseLeave(badge)
|
||||||
|
|
||||||
|
expect(handlers.onMouseEnter).toHaveBeenCalledTimes(1)
|
||||||
|
expect(handlers.onClick).toHaveBeenCalledTimes(1)
|
||||||
|
expect(handlers.onMouseLeave).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass event object to handler with correct properties', () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
render(<Badge onClick={handleClick}>Event Badge</Badge>)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Event Badge'))
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'click',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Combined props', () => {
|
||||||
|
it('should correctly apply all props when used together', () => {
|
||||||
|
render(
|
||||||
|
<Badge
|
||||||
|
size="l"
|
||||||
|
state={BadgeState.Warning}
|
||||||
|
uppercase
|
||||||
|
className="custom-badge"
|
||||||
|
styleCss={{ backgroundColor: 'rgb(0, 0, 255)' }}
|
||||||
|
data-testid="combined-badge"
|
||||||
|
>
|
||||||
|
Full Featured
|
||||||
|
</Badge>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const badge = screen.getByTestId('combined-badge')
|
||||||
|
expect(badge).toHaveClass('badge', 'badge-l', 'badge-warning', 'system-2xs-medium-uppercase', 'custom-badge')
|
||||||
|
expect(badge).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' })
|
||||||
|
expect(badge).toHaveTextContent('Full Featured')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
props: { size: 'l' as const, state: BadgeState.Accent },
|
||||||
|
expected: ['badge', 'badge-l', 'badge-accent'],
|
||||||
|
label: 'size and state variants',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: { iconOnly: true, uppercase: true },
|
||||||
|
expected: ['badge', 'system-2xs-medium-uppercase'],
|
||||||
|
label: 'iconOnly and uppercase',
|
||||||
|
},
|
||||||
|
])('should combine $label correctly', ({ props, expected }) => {
|
||||||
|
render(<Badge {...props}>Test</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test')).toHaveClass(...expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle event handlers with combined props', () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
render(
|
||||||
|
<Badge size="s" state={BadgeState.Warning} onClick={handleClick} className="interactive">
|
||||||
|
Test
|
||||||
|
</Badge>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const badge = screen.getByText('Test')
|
||||||
|
expect(badge).toHaveClass('badge', 'badge-s', 'badge-warning', 'interactive')
|
||||||
|
|
||||||
|
fireEvent.click(badge)
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it.each([
|
||||||
|
{ children: 42, text: '42', label: 'numeric value' },
|
||||||
|
{ children: 0, text: '0', label: 'zero' },
|
||||||
|
])('should render $label correctly', ({ children, text }) => {
|
||||||
|
render(<Badge>{children}</Badge>)
|
||||||
|
|
||||||
|
expect(screen.getByText(text)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ children: null, label: 'null' },
|
||||||
|
{ children: false, label: 'boolean false' },
|
||||||
|
])('should handle $label children without errors', ({ children }) => {
|
||||||
|
const { container } = render(<Badge>{children}</Badge>)
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveClass('badge')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render complex nested content correctly', () => {
|
||||||
|
render(
|
||||||
|
<Badge>
|
||||||
|
<span data-testid="icon">🔔</span>
|
||||||
|
<span data-testid="count">5</span>
|
||||||
|
</Badge>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('count')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Component metadata and exports', () => {
|
||||||
|
it('should have correct displayName for debugging', () => {
|
||||||
|
expect(Badge.displayName).toBe('Badge')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BadgeState enum', () => {
|
||||||
|
it.each([
|
||||||
|
{ key: 'Warning', value: 'warning' },
|
||||||
|
{ key: 'Accent', value: 'accent' },
|
||||||
|
{ key: 'Default', value: '' },
|
||||||
|
])('should export $key state with value "$value"', ({ key, value }) => {
|
||||||
|
expect(BadgeState[key as keyof typeof BadgeState]).toBe(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BadgeVariants utility', () => {
|
||||||
|
it('should be a function', () => {
|
||||||
|
expect(typeof BadgeVariants).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate base badge class with default medium size', () => {
|
||||||
|
const result = BadgeVariants({})
|
||||||
|
|
||||||
|
expect(result).toContain('badge')
|
||||||
|
expect(result).toContain('badge-m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ size: 's' },
|
||||||
|
{ size: 'm' },
|
||||||
|
{ size: 'l' },
|
||||||
|
] as const)('should generate correct classes for size=$size', ({ size }) => {
|
||||||
|
const result = BadgeVariants({ size })
|
||||||
|
|
||||||
|
expect(result).toContain('badge')
|
||||||
|
expect(result).toContain(`badge-${size}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,394 @@
|
||||||
|
import type { Item } from './index'
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import Chip from './index'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
// Test data factory
|
||||||
|
const createTestItems = (): Item[] => [
|
||||||
|
{ value: 'all', name: 'All Items' },
|
||||||
|
{ value: 'active', name: 'Active' },
|
||||||
|
{ value: 'archived', name: 'Archived' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('Chip', () => {
|
||||||
|
// Shared test props
|
||||||
|
let items: Item[]
|
||||||
|
let onSelect: (item: Item) => void
|
||||||
|
let onClear: () => void
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
items = createTestItems()
|
||||||
|
onSelect = vi.fn()
|
||||||
|
onClear = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to render Chip with default props
|
||||||
|
const renderChip = (props: Partial<React.ComponentProps<typeof Chip>> = {}) => {
|
||||||
|
return render(
|
||||||
|
<Chip
|
||||||
|
value="all"
|
||||||
|
items={items}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClear={onClear}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get the trigger element
|
||||||
|
const getTrigger = (container: HTMLElement) => {
|
||||||
|
return container.querySelector('[data-state]')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to open dropdown panel
|
||||||
|
const openPanel = (container: HTMLElement) => {
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
if (trigger)
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render without crashing', () => {
|
||||||
|
renderChip()
|
||||||
|
|
||||||
|
expect(screen.getByText('All Items')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display current selected item name', () => {
|
||||||
|
renderChip({ value: 'active' })
|
||||||
|
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display empty content when value does not match any item', () => {
|
||||||
|
const { container } = renderChip({ value: 'nonexistent' })
|
||||||
|
|
||||||
|
// When value doesn't match, no text should be displayed in trigger
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
// Check that there's no item name text (only icons should be present)
|
||||||
|
expect(trigger?.textContent?.trim()).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Props', () => {
|
||||||
|
it('should update displayed item name when value prop changes', () => {
|
||||||
|
const { rerender } = renderChip({ value: 'all' })
|
||||||
|
expect(screen.getByText('All Items')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Chip
|
||||||
|
value="archived"
|
||||||
|
items={items}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClear={onClear}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Archived')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show left icon by default', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
// The filter icon should be visible
|
||||||
|
const svg = container.querySelector('svg')
|
||||||
|
expect(svg).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide left icon when showLeftIcon is false', () => {
|
||||||
|
renderChip({ showLeftIcon: false })
|
||||||
|
|
||||||
|
// When showLeftIcon is false, there should be no filter icon before the text
|
||||||
|
const textElement = screen.getByText('All Items')
|
||||||
|
const parent = textElement.closest('div[data-state]')
|
||||||
|
const icons = parent?.querySelectorAll('svg')
|
||||||
|
|
||||||
|
// Should only have the arrow icon, not the filter icon
|
||||||
|
expect(icons?.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render custom left icon', () => {
|
||||||
|
const CustomIcon = () => <span data-testid="custom-icon">★</span>
|
||||||
|
|
||||||
|
renderChip({ leftIcon: <CustomIcon /> })
|
||||||
|
|
||||||
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom className to trigger', () => {
|
||||||
|
const customClass = 'custom-chip-class'
|
||||||
|
|
||||||
|
const { container } = renderChip({ className: customClass })
|
||||||
|
|
||||||
|
const chipElement = container.querySelector(`.${customClass}`)
|
||||||
|
expect(chipElement).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom panelClassName to dropdown panel', () => {
|
||||||
|
const customPanelClass = 'custom-panel-class'
|
||||||
|
|
||||||
|
const { container } = renderChip({ panelClassName: customPanelClass })
|
||||||
|
openPanel(container)
|
||||||
|
|
||||||
|
// Panel is rendered in a portal, so check document.body
|
||||||
|
const panel = document.body.querySelector(`.${customPanelClass}`)
|
||||||
|
expect(panel).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('State Management', () => {
|
||||||
|
it('should toggle dropdown panel on trigger click', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
// Initially closed - check data-state attribute
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
|
||||||
|
// Open panel
|
||||||
|
openPanel(container)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||||
|
// Panel items should be visible
|
||||||
|
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
// Close panel
|
||||||
|
if (trigger)
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close panel after selecting an item', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
openPanel(container)
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||||
|
|
||||||
|
// Click on an item in the dropdown panel
|
||||||
|
const activeItems = screen.getAllByText('Active')
|
||||||
|
// The second one should be in the dropdown
|
||||||
|
fireEvent.click(activeItems[activeItems.length - 1])
|
||||||
|
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Event Handlers', () => {
|
||||||
|
it('should call onSelect with correct item when item is clicked', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
openPanel(container)
|
||||||
|
// Get all "Active" texts and click the one in the dropdown (should be the last one)
|
||||||
|
const activeItems = screen.getAllByText('Active')
|
||||||
|
fireEvent.click(activeItems[activeItems.length - 1])
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(items[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onClear when clear button is clicked', () => {
|
||||||
|
const { container } = renderChip({ value: 'active' })
|
||||||
|
|
||||||
|
// Find the close icon (last SVG in the trigger) and click its parent
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
const svgs = trigger?.querySelectorAll('svg')
|
||||||
|
// The close icon should be the last SVG element
|
||||||
|
const closeIcon = svgs?.[svgs.length - 1]
|
||||||
|
const clearButton = closeIcon?.parentElement
|
||||||
|
|
||||||
|
expect(clearButton).toBeInTheDocument()
|
||||||
|
if (clearButton)
|
||||||
|
fireEvent.click(clearButton)
|
||||||
|
|
||||||
|
expect(onClear).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop event propagation when clear button is clicked', () => {
|
||||||
|
const { container } = renderChip({ value: 'active' })
|
||||||
|
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
|
||||||
|
// Find the close icon (last SVG) and click its parent
|
||||||
|
const svgs = trigger?.querySelectorAll('svg')
|
||||||
|
const closeIcon = svgs?.[svgs.length - 1]
|
||||||
|
const clearButton = closeIcon?.parentElement
|
||||||
|
|
||||||
|
if (clearButton)
|
||||||
|
fireEvent.click(clearButton)
|
||||||
|
|
||||||
|
// Panel should remain closed
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
expect(onClear).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple rapid clicks on trigger', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
|
||||||
|
// Click 1: open
|
||||||
|
if (trigger)
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||||
|
|
||||||
|
// Click 2: close
|
||||||
|
if (trigger)
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
|
||||||
|
// Click 3: open again
|
||||||
|
if (trigger)
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Conditional Rendering', () => {
|
||||||
|
it('should show arrow down icon when no value is selected', () => {
|
||||||
|
const { container } = renderChip({ value: '' })
|
||||||
|
|
||||||
|
// Should have SVG icons (filter icon and arrow down icon)
|
||||||
|
const svgs = container.querySelectorAll('svg')
|
||||||
|
expect(svgs.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show clear button when value is selected', () => {
|
||||||
|
const { container } = renderChip({ value: 'active' })
|
||||||
|
|
||||||
|
// When value is selected, there should be an icon (the close icon)
|
||||||
|
const svgs = container.querySelectorAll('svg')
|
||||||
|
expect(svgs.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show clear button when no value is selected', () => {
|
||||||
|
const { container } = renderChip({ value: '' })
|
||||||
|
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
|
||||||
|
// When value is empty, the trigger should only have 2 SVGs (filter icon + arrow)
|
||||||
|
// When value is selected, it would have 2 SVGs (filter icon + close icon)
|
||||||
|
const svgs = trigger?.querySelectorAll('svg')
|
||||||
|
// Arrow icon should be present, close icon should not
|
||||||
|
expect(svgs?.length).toBe(2)
|
||||||
|
|
||||||
|
// Verify onClear hasn't been called
|
||||||
|
expect(onClear).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show dropdown content only when panel is open', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
|
||||||
|
// Closed by default
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||||
|
|
||||||
|
openPanel(container)
|
||||||
|
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||||
|
// Items should be duplicated (once in trigger, once in panel)
|
||||||
|
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show check icon on selected item in dropdown', () => {
|
||||||
|
const { container } = renderChip({ value: 'active' })
|
||||||
|
|
||||||
|
openPanel(container)
|
||||||
|
|
||||||
|
// Find the dropdown panel items
|
||||||
|
const allActiveTexts = screen.getAllByText('Active')
|
||||||
|
// The dropdown item should be the last one
|
||||||
|
const dropdownItem = allActiveTexts[allActiveTexts.length - 1]
|
||||||
|
const parentContainer = dropdownItem.parentElement
|
||||||
|
|
||||||
|
// The check icon should be a sibling within the parent
|
||||||
|
const checkIcon = parentContainer?.querySelector('svg')
|
||||||
|
expect(checkIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render all items in dropdown when open', () => {
|
||||||
|
const { container } = renderChip()
|
||||||
|
|
||||||
|
openPanel(container)
|
||||||
|
|
||||||
|
// Each item should appear at least twice (once in potential selected state, once in dropdown)
|
||||||
|
// Use getAllByText to handle multiple occurrences
|
||||||
|
expect(screen.getAllByText('All Items').length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getAllByText('Archived').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty items array', () => {
|
||||||
|
const { container } = renderChip({ items: [], value: '' })
|
||||||
|
|
||||||
|
// Trigger should still render
|
||||||
|
const trigger = container.querySelector('[data-state]')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle value not in items list', () => {
|
||||||
|
const { container } = renderChip({ value: 'nonexistent' })
|
||||||
|
|
||||||
|
const trigger = getTrigger(container)
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
|
||||||
|
// The trigger should not display any item name text
|
||||||
|
expect(trigger?.textContent?.trim()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow selecting already selected item', () => {
|
||||||
|
const { container } = renderChip({ value: 'active' })
|
||||||
|
|
||||||
|
openPanel(container)
|
||||||
|
|
||||||
|
// Click on the already selected item in the dropdown
|
||||||
|
const activeItems = screen.getAllByText('Active')
|
||||||
|
fireEvent.click(activeItems[activeItems.length - 1])
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(items[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle numeric values', () => {
|
||||||
|
const numericItems: Item[] = [
|
||||||
|
{ value: 1, name: 'First' },
|
||||||
|
{ value: 2, name: 'Second' },
|
||||||
|
{ value: 3, name: 'Third' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { container } = renderChip({ value: 2, items: numericItems })
|
||||||
|
|
||||||
|
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Open panel and select Third
|
||||||
|
openPanel(container)
|
||||||
|
|
||||||
|
const thirdItems = screen.getAllByText('Third')
|
||||||
|
fireEvent.click(thirdItems[thirdItems.length - 1])
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(numericItems[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle items with additional properties', () => {
|
||||||
|
const itemsWithExtra: Item[] = [
|
||||||
|
{ value: 'a', name: 'Item A', customProp: 'extra1' },
|
||||||
|
{ value: 'b', name: 'Item B', customProp: 'extra2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { container } = renderChip({ value: 'a', items: itemsWithExtra })
|
||||||
|
|
||||||
|
expect(screen.getByText('Item A')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Open panel and select Item B
|
||||||
|
openPanel(container)
|
||||||
|
|
||||||
|
const itemBs = screen.getAllByText('Item B')
|
||||||
|
fireEvent.click(itemBs[itemBs.length - 1])
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(itemsWithExtra[1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -195,9 +195,11 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||||
const { nodesReadOnly } = useNodesReadOnly()
|
const { nodesReadOnly } = useNodesReadOnly()
|
||||||
const { eventEmitter } = useEventEmitterContextContext()
|
const { eventEmitter } = useEventEmitterContextContext()
|
||||||
|
|
||||||
|
const store = useStoreApi()
|
||||||
eventEmitter?.useSubscription((v: any) => {
|
eventEmitter?.useSubscription((v: any) => {
|
||||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||||
setNodes(v.payload.nodes)
|
setNodes(v.payload.nodes)
|
||||||
|
store.getState().setNodes(v.payload.nodes)
|
||||||
setEdges(v.payload.edges)
|
setEdges(v.payload.edges)
|
||||||
|
|
||||||
if (v.payload.viewport)
|
if (v.payload.viewport)
|
||||||
|
|
@ -359,7 +361,6 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||||
}
|
}
|
||||||
}, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList])
|
}, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList])
|
||||||
|
|
||||||
const store = useStoreApi()
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
store.getState().onError = (code, message) => {
|
store.getState().onError = (code, message) => {
|
||||||
if (code === '002')
|
if (code === '002')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue