Merge branch 'main' into feat/llm-node-support-tools

This commit is contained in:
zxhlyh 2025-12-25 13:45:49 +08:00
commit 0cff94d90e
427 changed files with 6016 additions and 2071 deletions

View File

@ -49,10 +49,10 @@ pnpm test
pnpm test:watch
# Run specific file
pnpm test -- path/to/file.spec.tsx
pnpm test path/to/file.spec.tsx
# Generate coverage report
pnpm test -- --coverage
pnpm test:coverage
# Analyze component complexity
pnpm analyze-component <path>
@ -155,7 +155,7 @@ describe('ComponentName', () => {
For each file:
┌────────────────────────────────────────┐
│ 1. Write test │
│ 2. Run: pnpm test -- <file>.spec.tsx │
│ 2. Run: pnpm test <file>.spec.tsx
│ 3. PASS? → Mark complete, next file │
│ FAIL? → Fix first, then continue │
└────────────────────────────────────────┘

View File

@ -198,7 +198,7 @@ describe('ComponentName', () => {
})
// --------------------------------------------------------------------------
// Async Operations (if component fetches data - useSWR, useQuery, fetch)
// Async Operations (if component fetches data - useQuery, fetch)
// --------------------------------------------------------------------------
// WHY: Async operations have 3 states users experience: loading, success, error
describe('Async Operations', () => {

View File

@ -114,15 +114,15 @@ For the current file being tested:
**Run these checks after EACH test file, not just at the end:**
- [ ] Run `pnpm test -- path/to/file.spec.tsx` - **MUST PASS before next file**
- [ ] Run `pnpm test path/to/file.spec.tsx` - **MUST PASS before next file**
- [ ] Fix any failures immediately
- [ ] Mark file as complete in todo list
- [ ] Only then proceed to next file
### After All Files Complete
- [ ] Run full directory test: `pnpm test -- path/to/directory/`
- [ ] Check coverage report: `pnpm test -- --coverage`
- [ ] Run full directory test: `pnpm test path/to/directory/`
- [ ] Check coverage report: `pnpm test:coverage`
- [ ] Run `pnpm lint:fix` on all test files
- [ ] Run `pnpm type-check:tsgo`
@ -186,16 +186,16 @@ Always test these scenarios:
```bash
# Run specific test
pnpm test -- path/to/file.spec.tsx
pnpm test path/to/file.spec.tsx
# Run with coverage
pnpm test -- --coverage path/to/file.spec.tsx
pnpm test:coverage path/to/file.spec.tsx
# Watch mode
pnpm test:watch -- path/to/file.spec.tsx
pnpm test:watch path/to/file.spec.tsx
# Update snapshots (use sparingly)
pnpm test -- -u path/to/file.spec.tsx
pnpm test -u path/to/file.spec.tsx
# Analyze component
pnpm analyze-component path/to/component.tsx

View File

@ -242,32 +242,9 @@ describe('Component with Context', () => {
})
```
### 7. SWR / React Query
### 7. React Query
```typescript
// SWR
vi.mock('swr', () => ({
__esModule: true,
default: vi.fn(),
}))
import useSWR from 'swr'
const mockedUseSWR = vi.mocked(useSWR)
describe('Component with SWR', () => {
it('should show loading state', () => {
mockedUseSWR.mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
})
render(<Component />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
// React Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const createTestQueryClient = () => new QueryClient({

View File

@ -35,7 +35,7 @@ When testing a **single component, hook, or utility**:
2. Run `pnpm analyze-component <path>` (if available)
3. Check complexity score and features detected
4. Write the test file
5. Run test: `pnpm test -- <file>.spec.tsx`
5. Run test: `pnpm test <file>.spec.tsx`
6. Fix any failures
7. Verify coverage meets goals (100% function, >95% branch)
```
@ -80,7 +80,7 @@ Process files in this recommended order:
```
┌─────────────────────────────────────────────┐
│ 1. Write test file │
│ 2. Run: pnpm test -- <file>.spec.tsx │
│ 2. Run: pnpm test <file>.spec.tsx
│ 3. If FAIL → Fix immediately, re-run │
│ 4. If PASS → Mark complete in todo list │
│ 5. ONLY THEN proceed to next file │
@ -95,10 +95,10 @@ After all individual tests pass:
```bash
# Run all tests in the directory together
pnpm test -- path/to/directory/
pnpm test path/to/directory/
# Check coverage
pnpm test -- --coverage path/to/directory/
pnpm test:coverage path/to/directory/
```
## Component Complexity Guidelines
@ -201,9 +201,9 @@ Run pnpm test ← Multiple failures, hard to debug
```
# GOOD: Incremental with verification
Write component-a.spec.tsx
Run pnpm test -- component-a.spec.tsx ✅
Run pnpm test component-a.spec.tsx ✅
Write component-b.spec.tsx
Run pnpm test -- component-b.spec.tsx ✅
Run pnpm test component-b.spec.tsx ✅
...continue...
```

View File

@ -13,12 +13,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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
with:
python-version: "3.11"
- 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: |
cd api
uv sync --dev

View File

@ -108,36 +108,6 @@ jobs:
working-directory: ./web
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:
name: SuperLinter
runs-on: ubuntu-latest

View File

@ -1,4 +1,4 @@
name: Check i18n Files and Create PR
name: Translate i18n Files Based on English
on:
push:
@ -67,25 +67,19 @@ jobs:
working-directory: ./web
run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
- name: Generate i18n type definitions
if: env.FILES_CHANGED == 'true'
working-directory: ./web
run: pnpm run gen:i18n-types
- name: Create Pull Request
if: env.FILES_CHANGED == 'true'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore(i18n): update translations based on en-US changes'
title: 'chore(i18n): translate i18n files and update type definitions'
title: 'chore(i18n): translate i18n files based on en-US changes'
body: |
This PR was automatically created to update i18n files and TypeScript type definitions based on changes in en-US locale.
This PR was automatically created to update i18n translation files based on changes in en-US locale.
**Triggered by:** ${{ github.sha }}
**Changes included:**
- Updated translation files for all locales
- Regenerated TypeScript type definitions for type safety
branch: chore/automated-i18n-updates-${{ github.sha }}
delete-branch: true

View File

@ -38,11 +38,8 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check i18n types synchronization
run: pnpm run check:i18n-types
- name: Run tests
run: pnpm test --coverage
run: pnpm test:coverage
- name: Coverage Summary
if: always()

View File

@ -1,5 +1,6 @@
import io
from typing import Literal
from collections.abc import Mapping
from typing import Any, Literal
from flask import request, send_file
from flask_restx import Resource
@ -141,6 +142,15 @@ class ParserDynamicOptions(BaseModel):
provider_type: Literal["tool", "trigger"]
class ParserDynamicOptionsWithCredentials(BaseModel):
plugin_id: str
provider: str
action: str
parameter: str
credential_id: str
credentials: Mapping[str, Any]
class PluginPermissionSettingsPayload(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
@ -183,6 +193,7 @@ reg(ParserGithubUpgrade)
reg(ParserUninstall)
reg(ParserPermissionChange)
reg(ParserDynamicOptions)
reg(ParserDynamicOptionsWithCredentials)
reg(ParserPreferencesChange)
reg(ParserExcludePlugin)
reg(ParserReadme)
@ -657,6 +668,37 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/parameters/dynamic-options-with-credentials")
class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
@console_ns.expect(console_ns.models[ParserDynamicOptionsWithCredentials.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self):
"""Fetch dynamic options using credentials directly (for edit mode)."""
current_user, tenant_id = current_account_with_tenant()
user_id = current_user.id
args = ParserDynamicOptionsWithCredentials.model_validate(console_ns.payload)
try:
options = PluginParameterService.get_dynamic_select_options_with_credentials(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=args.plugin_id,
provider=args.provider,
action=args.action,
parameter=args.parameter,
credential_id=args.credential_id,
credentials=args.credentials,
)
except PluginDaemonClientSideError as e:
raise ValueError(e)
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])

View File

@ -1,11 +1,15 @@
import logging
from collections.abc import Mapping
from typing import Any
from flask import make_response, redirect, request
from flask_restx import Resource, reqparse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from controllers.web.error import NotFoundError
from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin_daemon import CredentialType
@ -32,6 +36,32 @@ from ..wraps import (
logger = logging.getLogger(__name__)
class TriggerSubscriptionUpdateRequest(BaseModel):
"""Request payload for updating a trigger subscription"""
name: str | None = Field(default=None, description="The name for the subscription")
credentials: Mapping[str, Any] | None = Field(default=None, description="The credentials for the subscription")
parameters: Mapping[str, Any] | None = Field(default=None, description="The parameters for the subscription")
properties: Mapping[str, Any] | None = Field(default=None, description="The properties for the subscription")
class TriggerSubscriptionVerifyRequest(BaseModel):
"""Request payload for verifying subscription credentials."""
credentials: Mapping[str, Any] = Field(description="The credentials to verify")
console_ns.schema_model(
TriggerSubscriptionUpdateRequest.__name__,
TriggerSubscriptionUpdateRequest.model_json_schema(ref_template="#/definitions/{model}"),
)
console_ns.schema_model(
TriggerSubscriptionVerifyRequest.__name__,
TriggerSubscriptionVerifyRequest.model_json_schema(ref_template="#/definitions/{model}"),
)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/icon")
class TriggerProviderIconApi(Resource):
@setup_required
@ -155,16 +185,16 @@ parser_api = (
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/verify/<path:subscription_builder_id>",
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/verify-and-update/<path:subscription_builder_id>",
)
class TriggerSubscriptionBuilderVerifyApi(Resource):
class TriggerSubscriptionBuilderVerifyAndUpdateApi(Resource):
@console_ns.expect(parser_api)
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
def post(self, provider, subscription_builder_id):
"""Verify a subscription instance for a trigger provider"""
"""Verify and update a subscription instance for a trigger provider"""
user = current_user
assert user.current_tenant_id is not None
@ -289,6 +319,83 @@ class TriggerSubscriptionBuilderBuildApi(Resource):
raise ValueError(str(e)) from e
@console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/update",
)
class TriggerSubscriptionUpdateApi(Resource):
@console_ns.expect(console_ns.models[TriggerSubscriptionUpdateRequest.__name__])
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
def post(self, subscription_id: str):
"""Update a subscription instance"""
user = current_user
assert user.current_tenant_id is not None
args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise NotFoundError(f"Subscription {subscription_id} not found")
provider_id = TriggerProviderID(subscription.provider_id)
try:
# rename only
if (
args.name is not None
and args.credentials is None
and args.parameters is None
and args.properties is None
):
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
)
return 200
# rebuild for create automatically by the provider
match subscription.credential_type:
case CredentialType.UNAUTHORIZED:
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
properties=args.properties,
)
return 200
case CredentialType.API_KEY | CredentialType.OAUTH2:
if args.credentials:
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in args.credentials.items()
}
else:
new_credentials = subscription.credentials
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=user.current_tenant_id,
name=args.name,
provider_id=provider_id,
subscription_id=subscription_id,
credentials=new_credentials,
parameters=args.parameters or subscription.parameters,
)
return 200
case _:
raise BadRequest("Invalid credential type")
except ValueError as e:
raise BadRequest(str(e))
except Exception as e:
logger.exception("Error updating subscription", exc_info=e)
raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete",
)
@ -576,3 +683,38 @@ class TriggerOAuthClientManageApi(Resource):
except Exception as e:
logger.exception("Error removing OAuth client", exc_info=e)
raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/verify/<path:subscription_id>",
)
class TriggerSubscriptionVerifyApi(Resource):
@console_ns.expect(console_ns.models[TriggerSubscriptionVerifyRequest.__name__])
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
def post(self, provider, subscription_id):
"""Verify credentials for an existing subscription (edit mode only)"""
user = current_user
assert user.current_tenant_id is not None
verify_request: TriggerSubscriptionVerifyRequest = TriggerSubscriptionVerifyRequest.model_validate(
console_ns.payload
)
try:
result = TriggerProviderService.verify_subscription_credentials(
tenant_id=user.current_tenant_id,
user_id=user.id,
provider_id=TriggerProviderID(provider),
subscription_id=subscription_id,
credentials=verify_request.credentials,
)
return result
except ValueError as e:
logger.warning("Credential verification failed", exc_info=e)
raise BadRequest(str(e)) from e
except Exception as e:
logger.exception("Error verifying subscription credentials", exc_info=e)
raise BadRequest(str(e)) from e

View File

@ -118,13 +118,11 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs):
# Build the request manually to preserve the Host header
# httpx may override the Host header when using a proxy, so we use
# the request API to explicitly set headers before sending
request = client.build_request(method=method, url=url, **kwargs)
# If user explicitly provided a Host header, ensure it's preserved
headers = {k: v for k, v in headers.items() if k.lower() != "host"}
if user_provided_host is not None:
request.headers["Host"] = user_provided_host
response = client.send(request)
headers["host"] = user_provided_host
kwargs["headers"] = headers
response = client.request(method=method, url=url, **kwargs)
# Check for SSRF protection by Squid proxy
if response.status_code in (401, 403):

View File

@ -153,11 +153,11 @@ class ToolInvokeMessage(BaseModel):
@classmethod
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")
if not isinstance(value, dict | list | str | int | float | bool):
raise ValueError("Only basic types and lists are allowed.")
if value is not None and not isinstance(value, dict | list | str | int | float | bool):
raise ValueError("Only basic types, lists, and None are allowed.")
# if stream is true, the value must be a string
if values.get("stream"):

View File

@ -67,12 +67,16 @@ def create_trigger_provider_encrypter_for_subscription(
def delete_cache_for_subscription(tenant_id: str, provider_id: str, subscription_id: str):
cache = TriggerProviderCredentialsCache(
TriggerProviderCredentialsCache(
tenant_id=tenant_id,
provider_id=provider_id,
credential_id=subscription_id,
)
cache.delete()
).delete()
TriggerProviderPropertiesCache(
tenant_id=tenant_id,
provider_id=provider_id,
subscription_id=subscription_id,
).delete()
def create_trigger_provider_encrypter_for_properties(

View File

@ -281,7 +281,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
# handle invoke result
text = invoke_result.message.content or ""
text = invoke_result.message.get_text_content()
if not isinstance(text, str):
raise InvalidTextContentTypeError(f"Invalid text content type: {type(text)}. Expected str.")

View File

@ -105,3 +105,49 @@ class PluginParameterService:
)
.options
)
@staticmethod
def get_dynamic_select_options_with_credentials(
tenant_id: str,
user_id: str,
plugin_id: str,
provider: str,
action: str,
parameter: str,
credential_id: str,
credentials: Mapping[str, Any],
) -> Sequence[PluginParameterOption]:
"""
Get dynamic select options using provided credentials directly.
Used for edit mode when credentials have been modified but not yet saved.
Security: credential_id is validated against tenant_id to ensure
users can only access their own credentials.
"""
from constants import HIDDEN_VALUE
# Get original subscription to replace hidden values (with tenant_id check for security)
original_subscription = TriggerProviderService.get_subscription_by_id(tenant_id, credential_id)
if not original_subscription:
raise ValueError(f"Subscription {credential_id} not found")
# Replace [__HIDDEN__] with original values
resolved_credentials: dict[str, Any] = {
key: (original_subscription.credentials.get(key) if value == HIDDEN_VALUE else value)
for key, value in credentials.items()
}
return (
DynamicSelectClient()
.fetch_dynamic_select_options(
tenant_id,
user_id,
plugin_id,
provider,
action,
resolved_credentials,
CredentialType.API_KEY.value,
parameter,
)
.options
)

View File

@ -94,16 +94,23 @@ class TriggerProviderService:
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
for subscription in subscriptions:
encrypter, _ = create_trigger_provider_encrypter_for_subscription(
credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription(
tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
subscription.credentials = dict(
encrypter.mask_credentials(dict(encrypter.decrypt(subscription.credentials)))
credential_encrypter.mask_credentials(dict(credential_encrypter.decrypt(subscription.credentials)))
)
subscription.properties = dict(encrypter.mask_credentials(dict(encrypter.decrypt(subscription.properties))))
subscription.parameters = dict(encrypter.mask_credentials(dict(encrypter.decrypt(subscription.parameters))))
properties_encrypter, _ = create_trigger_provider_encrypter_for_properties(
tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
subscription.properties = dict(
properties_encrypter.mask_credentials(dict(properties_encrypter.decrypt(subscription.properties)))
)
subscription.parameters = dict(subscription.parameters)
count = workflows_in_use_map.get(subscription.id)
subscription.workflows_in_use = count if count is not None else 0
@ -209,6 +216,101 @@ class TriggerProviderService:
logger.exception("Failed to add trigger provider")
raise ValueError(str(e))
@classmethod
def update_trigger_subscription(
cls,
tenant_id: str,
subscription_id: str,
name: str | None = None,
properties: Mapping[str, Any] | None = None,
parameters: Mapping[str, Any] | None = None,
credentials: Mapping[str, Any] | None = None,
credential_expires_at: int | None = None,
expires_at: int | None = None,
) -> None:
"""
Update an existing trigger subscription.
:param tenant_id: Tenant ID
:param subscription_id: Subscription instance ID
:param name: Optional new name for this subscription
:param properties: Optional new properties
:param parameters: Optional new parameters
:param credentials: Optional new credentials
:param credential_expires_at: Optional new credential expiration timestamp
:param expires_at: Optional new expiration timestamp
:return: Success response with updated subscription info
"""
with Session(db.engine, expire_on_commit=False) as session:
# Use distributed lock to prevent race conditions on the same subscription
lock_key = f"trigger_subscription_update_lock:{tenant_id}_{subscription_id}"
with redis_client.lock(lock_key, timeout=20):
subscription: TriggerSubscription | None = (
session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first()
)
if not subscription:
raise ValueError(f"Trigger subscription {subscription_id} not found")
provider_id = TriggerProviderID(subscription.provider_id)
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
# Check for name uniqueness if name is being updated
if name is not None and name != subscription.name:
existing = (
session.query(TriggerSubscription)
.filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name)
.first()
)
if existing:
raise ValueError(f"Subscription name '{name}' already exists for this provider")
subscription.name = name
# Update properties if provided
if properties is not None:
properties_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_properties_schema(),
cache=NoOpProviderCredentialCache(),
)
# Handle hidden values - preserve original encrypted values
original_properties = properties_encrypter.decrypt(subscription.properties)
new_properties: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else original_properties.get(key, UNKNOWN_VALUE)
for key, value in properties.items()
}
subscription.properties = dict(properties_encrypter.encrypt(new_properties))
# Update parameters if provided
if parameters is not None:
subscription.parameters = dict(parameters)
# Update credentials if provided
if credentials is not None:
credential_type = CredentialType.of(subscription.credential_type)
credential_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_credential_schema_config(credential_type),
cache=NoOpProviderCredentialCache(),
)
subscription.credentials = dict(credential_encrypter.encrypt(dict(credentials)))
# Update credential expiration timestamp if provided
if credential_expires_at is not None:
subscription.credential_expires_at = credential_expires_at
# Update expiration timestamp if provided
if expires_at is not None:
subscription.expires_at = expires_at
session.commit()
# Clear subscription cache
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=subscription.provider_id,
subscription_id=subscription.id,
)
@classmethod
def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None:
"""
@ -257,17 +359,18 @@ class TriggerProviderService:
raise ValueError(f"Trigger provider subscription {subscription_id} not found")
credential_type: CredentialType = CredentialType.of(subscription.credential_type)
provider_id = TriggerProviderID(subscription.provider_id)
provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=tenant_id, provider_id=provider_id
)
encrypter, _ = create_trigger_provider_encrypter_for_subscription(
tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY]
if is_auto_created:
provider_id = TriggerProviderID(subscription.provider_id)
provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=tenant_id, provider_id=provider_id
)
encrypter, _ = create_trigger_provider_encrypter_for_subscription(
tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
try:
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
@ -280,8 +383,8 @@ class TriggerProviderService:
except Exception as e:
logger.exception("Error unsubscribing trigger", exc_info=e)
# Clear cache
session.delete(subscription)
# Clear cache
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=subscription.provider_id,
@ -688,3 +791,125 @@ class TriggerProviderService:
)
subscription.properties = dict(properties_encrypter.decrypt(subscription.properties))
return subscription
@classmethod
def verify_subscription_credentials(
cls,
tenant_id: str,
user_id: str,
provider_id: TriggerProviderID,
subscription_id: str,
credentials: Mapping[str, Any],
) -> dict[str, Any]:
"""
Verify credentials for an existing subscription without updating it.
This is used in edit mode to validate new credentials before rebuild.
:param tenant_id: Tenant ID
:param user_id: User ID
:param provider_id: Provider identifier
:param subscription_id: Subscription ID
:param credentials: New credentials to verify
:return: dict with 'verified' boolean
"""
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
if not provider_controller:
raise ValueError(f"Provider {provider_id} not found")
subscription = cls.get_subscription_by_id(
tenant_id=tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
credential_type = CredentialType.of(subscription.credential_type)
# For API Key, validate the new credentials
if credential_type == CredentialType.API_KEY:
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
try:
provider_controller.validate_credentials(user_id, credentials=new_credentials)
return {"verified": True}
except Exception as e:
raise ValueError(f"Invalid credentials: {e}") from e
return {"verified": True}
@classmethod
def rebuild_trigger_subscription(
cls,
tenant_id: str,
provider_id: TriggerProviderID,
subscription_id: str,
credentials: Mapping[str, Any],
parameters: Mapping[str, Any],
name: str | None = None,
) -> None:
"""
Create a subscription builder for rebuilding an existing subscription.
This method creates a builder pre-filled with data from the rebuild request,
keeping the same subscription_id and endpoint_id so the webhook URL remains unchanged.
:param tenant_id: Tenant ID
:param name: Name for the subscription
:param subscription_id: Subscription ID
:param provider_id: Provider identifier
:param credentials: Credentials for the subscription
:param parameters: Parameters for the subscription
:return: SubscriptionBuilderApiEntity
"""
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
if not provider_controller:
raise ValueError(f"Provider {provider_id} not found")
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
credential_type = CredentialType.of(subscription.credential_type)
if credential_type not in [CredentialType.OAUTH2, CredentialType.API_KEY]:
raise ValueError("Credential type not supported for rebuild")
# TODO: Trying to invoke update api of the plugin trigger provider
# FALLBACK: If the update api is not implemented, delete the previous subscription and create a new one
# Delete the previous subscription
user_id = subscription.user_id
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=subscription.credentials,
credential_type=credential_type,
)
# Create a new subscription with the same subscription_id and endpoint_id
new_subscription: TriggerSubscriptionEntity = TriggerManager.subscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id),
parameters=parameters,
credentials=credentials,
credential_type=credential_type,
)
TriggerProviderService.update_trigger_subscription(
tenant_id=tenant_id,
subscription_id=subscription.id,
name=name,
parameters=parameters,
credentials=credentials,
properties=new_subscription.properties,
expires_at=new_subscription.expires_at,
)

View File

@ -453,11 +453,12 @@ class TriggerSubscriptionBuilderService:
if not subscription_builder:
return None
# response to validation endpoint
controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=subscription_builder.tenant_id, provider_id=TriggerProviderID(subscription_builder.provider_id)
)
try:
# response to validation endpoint
controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=subscription_builder.tenant_id,
provider_id=TriggerProviderID(subscription_builder.provider_id),
)
dispatch_response: TriggerDispatchResponse = controller.dispatch(
request=request,
subscription=subscription_builder.to_subscription(),

View File

@ -1,11 +1,9 @@
import secrets
from unittest.mock import MagicMock, patch
import pytest
from core.helper.ssrf_proxy import (
SSRF_DEFAULT_MAX_RETRIES,
STATUS_FORCELIST,
_get_user_provided_host_header,
make_request,
)
@ -14,11 +12,10 @@ from core.helper.ssrf_proxy import (
@patch("core.helper.ssrf_proxy._get_ssrf_client")
def test_successful_request(mock_get_client):
mock_client = MagicMock()
mock_request = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_client.send.return_value = mock_response
mock_client.build_request.return_value = mock_request
mock_client.request.return_value = mock_response
mock_get_client.return_value = mock_client
response = make_request("GET", "http://example.com")
@ -28,11 +25,10 @@ def test_successful_request(mock_get_client):
@patch("core.helper.ssrf_proxy._get_ssrf_client")
def test_retry_exceed_max_retries(mock_get_client):
mock_client = MagicMock()
mock_request = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 500
mock_client.send.return_value = mock_response
mock_client.build_request.return_value = mock_request
mock_client.request.return_value = mock_response
mock_get_client.return_value = mock_client
with pytest.raises(Exception) as e:
@ -40,32 +36,6 @@ def test_retry_exceed_max_retries(mock_get_client):
assert str(e.value) == f"Reached maximum retries ({SSRF_DEFAULT_MAX_RETRIES - 1}) for URL http://example.com"
@patch("core.helper.ssrf_proxy._get_ssrf_client")
def test_retry_logic_success(mock_get_client):
mock_client = MagicMock()
mock_request = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 200
side_effects = []
for _ in range(SSRF_DEFAULT_MAX_RETRIES):
status_code = secrets.choice(STATUS_FORCELIST)
retry_response = MagicMock()
retry_response.status_code = status_code
side_effects.append(retry_response)
side_effects.append(mock_response)
mock_client.send.side_effect = side_effects
mock_client.build_request.return_value = mock_request
mock_get_client.return_value = mock_client
response = make_request("GET", "http://example.com", max_retries=SSRF_DEFAULT_MAX_RETRIES)
assert response.status_code == 200
assert mock_client.send.call_count == SSRF_DEFAULT_MAX_RETRIES + 1
assert mock_client.build_request.call_count == SSRF_DEFAULT_MAX_RETRIES + 1
class TestGetUserProvidedHostHeader:
"""Tests for _get_user_provided_host_header function."""
@ -111,14 +81,12 @@ def test_host_header_preservation_without_user_header(mock_get_client):
mock_response = MagicMock()
mock_response.status_code = 200
mock_client.send.return_value = mock_response
mock_client.build_request.return_value = mock_request
mock_client.request.return_value = mock_response
mock_get_client.return_value = mock_client
response = make_request("GET", "http://example.com")
assert response.status_code == 200
# build_request should be called without headers dict containing Host
mock_client.build_request.assert_called_once()
# Host should not be set if not provided by user
assert "Host" not in mock_request.headers or mock_request.headers.get("Host") is None
@ -132,31 +100,10 @@ def test_host_header_preservation_with_user_header(mock_get_client):
mock_response = MagicMock()
mock_response.status_code = 200
mock_client.send.return_value = mock_response
mock_client.build_request.return_value = mock_request
mock_client.request.return_value = mock_response
mock_get_client.return_value = mock_client
custom_host = "custom.example.com:8080"
response = make_request("GET", "http://example.com", headers={"Host": custom_host})
assert response.status_code == 200
# Verify build_request was called
mock_client.build_request.assert_called_once()
# Verify the Host header was set on the request object
assert mock_request.headers.get("Host") == custom_host
mock_client.send.assert_called_once_with(mock_request)
@patch("core.helper.ssrf_proxy._get_ssrf_client")
@pytest.mark.parametrize("host_key", ["host", "HOST"])
def test_host_header_preservation_case_insensitive(mock_get_client, host_key):
"""Test that Host header is preserved regardless of case."""
mock_client = MagicMock()
mock_request = MagicMock()
mock_request.headers = {}
mock_response = MagicMock()
mock_response.status_code = 200
mock_client.send.return_value = mock_response
mock_client.build_request.return_value = mock_request
mock_get_client.return_value = mock_client
response = make_request("GET", "http://example.com", headers={host_key: "api.example.com"})
assert mock_request.headers.get("Host") == "api.example.com"

View File

@ -1,6 +1,6 @@
import type { Plan, UsagePlanInfo } from '@/app/components/billing/type'
import type { ProviderContextState } from '@/context/provider-context'
import { merge, noop } from 'lodash-es'
import { merge, noop } from 'es-toolkit/compat'
import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests

View File

@ -104,7 +104,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
notify({
type,
message: t(`common.actionMsg.${message}`),
message: t(`common.actionMsg.${message}` as any) as string,
})
}

View File

@ -53,7 +53,7 @@ const LongTimeRangePicker: FC<Props> = ({
return (
<SimpleSelect
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}` as any) as string }))}
className="mt-0 !w-40"
notClearable={true}
onSelect={handleSelect}

View File

@ -4,7 +4,7 @@ import type { FC } from 'react'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'

View File

@ -66,7 +66,7 @@ const RangeSelector: FC<Props> = ({
}, [])
return (
<SimpleSelect
items={ranges.map(v => ({ ...v, name: t(`appLog.filter.period.${v.name}`) }))}
items={ranges.map(v => ({ ...v, name: t(`appLog.filter.period.${v.name}` as any) as string }))}
className="mt-0 !w-40"
notClearable={true}
onSelect={handleSelectRange}

View File

@ -1,15 +1,15 @@
import * as React from 'react'
import Form from '@/app/components/datasets/settings/form'
import { getLocaleOnServer, useTranslation as translate } from '@/i18n-config/server'
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
const Settings = async () => {
const locale = await getLocaleOnServer()
const { t } = await translate(locale, 'dataset-settings')
const { t } = await getTranslation(locale, 'dataset-settings')
return (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-y-0.5 px-6 pb-2 pt-3">
<div className="system-xl-semibold text-text-primary">{t('title')}</div>
<div className="system-xl-semibold text-text-primary">{t('title') as any}</div>
<div className="system-sm-regular text-text-tertiary">{t('desc')}</div>
</div>
<Form />

View File

@ -1,6 +1,6 @@
'use client'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'

View File

@ -1,4 +1,4 @@
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,5 +1,5 @@
'use client'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'

View File

@ -1,6 +1,6 @@
import type { ResponseError } from '@/service/fetch'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'

View File

@ -73,7 +73,7 @@ const DatasetInfo: FC<DatasetInfoProps> = ({
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
<div className="flex items-center gap-x-2">
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}

View File

@ -116,7 +116,7 @@ const DatasetSidebarDropdown = ({
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
<div className="flex items-center gap-x-2">
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,72 +1,332 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { Mock } from 'vitest'
import type { QueryParam } from './filter'
import type { AnnotationsCountResponse } from '@/models/log'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import useSWR from 'swr'
import * as useLogModule from '@/service/use-log'
import Filter from './filter'
vi.mock('swr', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/service/use-log')
vi.mock('@/service/log', () => ({
fetchAnnotationsCount: vi.fn(),
}))
const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock
const mockUseSWR = useSWR as unknown as Mock
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// ============================================================================
// Mock Return Value Factory
// ============================================================================
type MockQueryResult<T> = Pick<UseQueryResult<T>, 'data' | 'isLoading' | 'error' | 'refetch'>
const createMockQueryResult = <T,>(
overrides: Partial<MockQueryResult<T>> = {},
): MockQueryResult<T> => ({
data: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('Filter', () => {
const appId = 'app-1'
const childContent = 'child-content'
const defaultQueryParams: QueryParam = { keyword: '' }
beforeEach(() => {
vi.clearAllMocks()
})
it('should render nothing until annotation count is fetched', () => {
mockUseSWR.mockReturnValue({ data: undefined })
// --------------------------------------------------------------------------
// Rendering Tests (REQUIRED)
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render nothing when data is loading', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({ isLoading: true }),
)
const { container } = render(
<Filter
appId={appId}
queryParams={{ keyword: '' }}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Act
const { container } = renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
expect(container.firstChild).toBeNull()
expect(mockUseSWR).toHaveBeenCalledWith(
{ url: `/apps/${appId}/annotations/count` },
expect.any(Function),
)
// Assert
expect(container.firstChild).toBeNull()
})
it('should render nothing when data is undefined', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({ data: undefined, isLoading: false }),
)
// Act
const { container } = renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(container.firstChild).toBeNull()
})
it('should render filter and children when data is available', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 20 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
expect(screen.getByText(childContent)).toBeInTheDocument()
})
})
it('should propagate keyword changes and clearing behavior', () => {
mockUseSWR.mockReturnValue({ data: { total: 20 } })
const queryParams: QueryParam = { keyword: 'prefill' }
const setQueryParams = vi.fn()
// --------------------------------------------------------------------------
// Props Tests (REQUIRED)
// --------------------------------------------------------------------------
describe('Props', () => {
it('should call useAnnotationsCount with appId', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 10 },
isLoading: false,
}),
)
const { container } = render(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={setQueryParams}
>
<div>{childContent}</div>
</Filter>,
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
fireEvent.change(input, { target: { value: 'updated' } })
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
// Assert
expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId)
})
const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
fireEvent.click(clearButton)
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
it('should display keyword value in input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 10 },
isLoading: false,
}),
)
const queryParams: QueryParam = { keyword: 'test-keyword' }
expect(container).toHaveTextContent(childContent)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('test-keyword')
})
})
// --------------------------------------------------------------------------
// User Interactions
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call setQueryParams when typing in search input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 20 },
isLoading: false,
}),
)
const queryParams: QueryParam = { keyword: '' }
const setQueryParams = vi.fn()
renderWithQueryClient(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={setQueryParams}
>
<div>{childContent}</div>
</Filter>,
)
// Act
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'updated' } })
// Assert
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
})
it('should call setQueryParams with empty keyword when clearing input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 20 },
isLoading: false,
}),
)
const queryParams: QueryParam = { keyword: 'prefill' }
const setQueryParams = vi.fn()
renderWithQueryClient(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={setQueryParams}
>
<div>{childContent}</div>
</Filter>,
)
// Act
const input = screen.getByPlaceholderText('common.operation.search')
const clearButton = input.parentElement?.querySelector('div.cursor-pointer')
if (clearButton)
fireEvent.click(clearButton)
// Assert
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
})
})
// --------------------------------------------------------------------------
// Edge Cases (REQUIRED)
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty keyword in queryParams', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 5 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={{ keyword: '' }}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toHaveValue('')
})
it('should handle undefined keyword in queryParams', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 5 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={{ keyword: undefined }}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
it('should handle zero count', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
createMockQueryResult<AnnotationsCountResponse>({
data: { count: 0 },
isLoading: false,
}),
)
// Act
renderWithQueryClient(
<Filter
appId={appId}
queryParams={defaultQueryParams}
setQueryParams={vi.fn()}
>
<div>{childContent}</div>
</Filter>,
)
// Assert - should still render when count is 0
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
})
})

View File

@ -2,9 +2,8 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import Input from '@/app/components/base/input'
import { fetchAnnotationsCount } from '@/service/log'
import { useAnnotationsCount } from '@/service/use-log'
export type QueryParam = {
keyword?: string
@ -23,10 +22,9 @@ const Filter: FC<IFilterProps> = ({
setQueryParams,
children,
}) => {
// TODO: change fetch list api
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
const { data, isLoading } = useAnnotationsCount(appId)
const { t } = useTranslation()
if (!data)
if (isLoading || !data)
return null
return (
<div className="mb-2 flex flex-row flex-wrap items-center justify-between gap-2">

View File

@ -1,5 +1,6 @@
import type { ComponentProps } from 'react'
import type { AnnotationItemBasic } from '../type'
import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -166,7 +167,7 @@ type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
const renderComponent = (
props: Partial<HeaderOptionsProps> = {},
locale: string = LanguagesSupported[0] as string,
locale: Locale = LanguagesSupported[0],
) => {
const defaultProps: HeaderOptionsProps = {
appId: 'test-app-id',
@ -353,7 +354,7 @@ describe('HeaderOptions', () => {
})
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(vi.fn())
renderComponent({}, LanguagesSupported[1] as string)
renderComponent({}, LanguagesSupported[1])
await expandExportMenu(user)
@ -441,7 +442,7 @@ describe('HeaderOptions', () => {
view.rerender(
<I18NContext.Provider
value={{
locale: LanguagesSupported[0] as string,
locale: LanguagesSupported[0],
i18n: {},
setLocaleOnClient: vi.fn(),
}}

View File

@ -84,7 +84,7 @@ const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
<>
<Icon className="h-4 w-4 shrink-0 text-text-secondary" />
<div className="grow truncate">
<span className="system-sm-medium text-text-secondary">{t(`app.accessControlDialog.accessItems.${label}`)}</span>
<span className="system-sm-medium text-text-secondary">{t(`app.accessControlDialog.accessItems.${label}` as any) as string}</span>
</div>
</>
)

View File

@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react'
import FeaturePanel from './index'
describe('FeaturePanel', () => {
// Rendering behavior for standard layout.
describe('Rendering', () => {
it('should render the title and children when provided', () => {
// Arrange
render(
<FeaturePanel title="Panel Title">
<div>Panel Body</div>
</FeaturePanel>,
)
// Assert
expect(screen.getByText('Panel Title')).toBeInTheDocument()
expect(screen.getByText('Panel Body')).toBeInTheDocument()
})
})
// Prop-driven presentation details like icons, actions, and spacing.
describe('Props', () => {
it('should render header icon and right slot and apply header border', () => {
// Arrange
render(
<FeaturePanel
title="Feature"
headerIcon={<span>Icon</span>}
headerRight={<button type="button">Action</button>}
hasHeaderBottomBorder={true}
/>,
)
// Assert
expect(screen.getByText('Icon')).toBeInTheDocument()
expect(screen.getByText('Action')).toBeInTheDocument()
const header = screen.getByTestId('feature-panel-header')
expect(header).toHaveClass('border-b')
})
it('should apply custom className and remove padding when noBodySpacing is true', () => {
// Arrange
const { container } = render(
<FeaturePanel title="Spacing" className="custom-panel" noBodySpacing={true}>
<div>Body</div>
</FeaturePanel>,
)
// Assert
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass('custom-panel')
expect(root).toHaveClass('pb-0')
const body = screen.getByTestId('feature-panel-body')
expect(body).not.toHaveClass('mt-1')
expect(body).not.toHaveClass('px-3')
})
})
// Edge cases when optional content is missing.
describe('Edge Cases', () => {
it('should not render the body wrapper when children is undefined', () => {
// Arrange
render(<FeaturePanel title="No Body" />)
// Assert
expect(screen.queryByText('No Body')).toBeInTheDocument()
expect(screen.queryByText('Panel Body')).not.toBeInTheDocument()
expect(screen.queryByTestId('feature-panel-body')).not.toBeInTheDocument()
})
})
})

View File

@ -25,7 +25,7 @@ const FeaturePanel: FC<IFeaturePanelProps> = ({
return (
<div className={cn('rounded-xl border-l-[0.5px] border-t-[0.5px] border-effects-highlight bg-background-section-burn pb-3', noBodySpacing && 'pb-0', className)}>
{/* Header */}
<div className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')}>
<div className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')} data-testid="feature-panel-header">
<div className="flex h-8 items-center justify-between">
<div className="flex shrink-0 items-center space-x-1">
{headerIcon && <div className="flex h-6 w-6 items-center justify-center">{headerIcon}</div>}
@ -38,7 +38,7 @@ const FeaturePanel: FC<IFeaturePanelProps> = ({
</div>
{/* Body */}
{children && (
<div className={cn(!noBodySpacing && 'mt-1 px-3')}>
<div className={cn(!noBodySpacing && 'mt-1 px-3')} data-testid="feature-panel-body">
{children}
</div>
)}

View File

@ -4,7 +4,7 @@ import {
RiAddLine,
RiEditLine,
} from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'

View File

@ -4,8 +4,8 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { GenRes } from '@/service/debug'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -96,7 +96,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('appDebug.variableConfig.varName') }),
message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: t('appDebug.variableConfig.varName') }) as string,
})
return false
}
@ -216,7 +216,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }) as string,
})
return
}

View File

@ -98,7 +98,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
if (errorMsgKey) {
Toast.notify({
type: 'error',
message: t(errorMsgKey, { key: t(typeName) }),
message: t(errorMsgKey as any, { key: t(typeName as any) as string }) as string,
})
return false
}

View File

@ -23,7 +23,7 @@ const SelectTypeItem: FC<ISelectTypeItemProps> = ({
onClick,
}) => {
const { t } = useTranslation()
const typeName = t(`appDebug.variableConfig.${i18nFileTypeMap[type] || type}`)
const typeName = t(`appDebug.variableConfig.${i18nFileTypeMap[type] || type}` as any) as string
return (
<div

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ExternalDataTool } from '@/models/common'
import copy from 'copy-to-clipboard'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@ -141,7 +141,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [editorKey, setEditorKey] = useState(`${flowId}-0`)
const handleChooseTemplate = useCallback((key: string) => {
return () => {
const template = t(`appDebug.generate.template.${key}.instruction`)
const template = t(`appDebug.generate.template.${key}.instruction` as any) as string
setInstruction(template)
setEditorKey(`${flowId}-${Date.now()}`)
}
@ -322,7 +322,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
<TryLabel
key={item.key}
Icon={item.icon}
text={t(`appDebug.generate.template.${item.key}.name`)}
text={t(`appDebug.generate.template.${item.key}.name` as any) as string}
onClick={handleChooseTemplate(item.key)}
/>
))}

View File

@ -52,7 +52,7 @@ vi.mock('../debug/hooks', () => ({
useFormattingChangedDispatcher: vi.fn(() => vi.fn()),
}))
vi.mock('lodash-es', () => ({
vi.mock('es-toolkit/compat', () => ({
intersectionBy: vi.fn((...arrays) => {
// Mock realistic intersection behavior based on metadata name
const validArrays = arrays.filter(Array.isArray)

View File

@ -8,8 +8,8 @@ import type {
MetadataFilteringModeEnum,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { DataSet } from '@/models/datasets'
import { intersectionBy } from 'es-toolkit/compat'
import { produce } from 'immer'
import { intersectionBy } from 'lodash-es'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { ModelConfig } from '@/app/components/workflow/types'
import type {
DataSet,
@ -8,7 +9,6 @@ import type {
import type {
DatasetConfigs,
} from '@/models/debug'
import { noop } from 'lodash-es'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@ -33,17 +33,20 @@ type Props = {
selectedDatasets?: DataSet[]
isInWorkflow?: boolean
singleRetrievalModelConfig?: ModelConfig
onSingleRetrievalModelChange?: (config: ModelConfig) => void
onSingleRetrievalModelParamsChange?: (config: ModelConfig) => void
onSingleRetrievalModelChange?: ModelParameterModalProps['setModel']
onSingleRetrievalModelParamsChange?: ModelParameterModalProps['onCompletionParamsChange']
}
const noopModelChange: ModelParameterModalProps['setModel'] = () => {}
const noopParamsChange: ModelParameterModalProps['onCompletionParamsChange'] = () => {}
const ConfigContent: FC<Props> = ({
datasetConfigs,
onChange,
isInWorkflow,
singleRetrievalModelConfig: singleRetrievalConfig = {} as ModelConfig,
onSingleRetrievalModelChange = noop,
onSingleRetrievalModelParamsChange = noop,
onSingleRetrievalModelChange = noopModelChange,
onSingleRetrievalModelParamsChange = noopParamsChange,
selectedDatasets = [],
}) => {
const { t } = useTranslation()

View File

@ -1,4 +1,4 @@
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'

View File

@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'lodash-es'
import { isEqual } from 'es-toolkit/compat'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@ -295,7 +295,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
isExternal
rowClass={rowClass}
labelClass={labelClass}
t={t}
t={t as any}
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
@ -308,7 +308,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
t={t as any}
indexMethod={indexMethod}
retrievalConfig={retrievalConfig}
showMultiModalTip={showMultiModalTip}

View File

@ -1,7 +1,7 @@
'use client'
import type { ModelAndParameter } from '../types'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type DebugWithMultipleModelContextType = {

View File

@ -4,7 +4,7 @@ import type {
OnSend,
TextGenerationConfig,
} from '@/app/components/base/text-generation/types'
import { cloneDeep, noop } from 'lodash-es'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { memo } from 'react'
import TextGeneration from '@/app/components/app/text-generate/item'
import { TransferMethod } from '@/app/components/base/chat/types'

View File

@ -6,7 +6,7 @@ import type {
ChatConfig,
ChatItem,
} from '@/app/components/base/chat/types'
import cloneDeep from 'lodash-es/cloneDeep'
import { cloneDeep } from 'es-toolkit/compat'
import {
useCallback,
useRef,

View File

@ -11,9 +11,8 @@ import {
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
import { noop } from 'lodash-es'
import cloneDeep from 'lodash-es/cloneDeep'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,7 +1,7 @@
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug'
import { clone } from 'es-toolkit/compat'
import { produce } from 'immer'
import { clone } from 'lodash-es'
import { useState } from 'react'
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'

View File

@ -20,8 +20,8 @@ import type {
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks'
import { clone, isEqual } from 'es-toolkit/compat'
import { produce } from 'immer'
import { clone, isEqual } from 'lodash-es'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

View File

@ -3,7 +3,7 @@ import type {
CodeBasedExtensionItem,
ExternalDataTool,
} from '@/models/common'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import AppTypeSelector from '@/app/components/app/type-selector'
import { trackEvent } from '@/app/components/base/amplitude'
@ -24,7 +23,8 @@ import ExploreContext from '@/context/explore-context'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
import { fetchAppDetail, fetchAppList } from '@/service/explore'
import { fetchAppDetail } from '@/service/explore'
import { useExploreAppList } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
@ -70,23 +70,14 @@ const Apps = ({
})
const {
data: { categories, allList },
} = useSWR(
['/explore/apps'],
() =>
fetchAppList().then(({ categories, recommended_apps }) => ({
categories,
allList: recommended_apps.sort((a, b) => a.position - b.position),
})),
{
fallbackData: {
categories: [],
allList: [],
},
},
)
data,
isLoading,
} = useExploreAppList()
const filteredList = useMemo(() => {
if (!data)
return []
const { allList } = data
const filteredByCategory = allList.filter((item) => {
if (currCategory === allCategoriesEn)
return true
@ -107,7 +98,7 @@ const Apps = ({
return true
return false
})
}, [currentType, currCategory, allCategoriesEn, allList])
}, [currentType, currCategory, allCategoriesEn, data])
const searchFilteredList = useMemo(() => {
if (!searchKeywords || !filteredList || filteredList.length === 0)
@ -169,7 +160,7 @@ const Apps = ({
}
}
if (!categories || categories.length === 0) {
if (isLoading) {
return (
<div className="flex h-full items-center">
<Loading type="area" />
@ -203,7 +194,7 @@ const Apps = ({
<div className="relative flex flex-1 overflow-y-auto">
{!searchKeywords && (
<div className="h-full w-[200px] p-4">
<Sidebar current={currCategory as AppCategories} categories={categories} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
<Sidebar current={currCategory as AppCategories} categories={data?.categories || []} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
</div>
)}
<div className="h-full flex-1 shrink-0 grow overflow-auto border-l border-divider-burn p-6 pt-2">

View File

@ -3,7 +3,7 @@
import type { MouseEventHandler } from 'react'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,7 +1,7 @@
'use client'
import type { AppIconType } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -0,0 +1,176 @@
import type { App, AppIconType } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import { AppModeEnum } from '@/types/app'
import LogAnnotation from './index'
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/app/components/app/annotation', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="annotation" data-app-id={appDetail.id} />
),
}))
vi.mock('@/app/components/app/log', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="log" data-app-id={appDetail.id} />
),
}))
vi.mock('@/app/components/app/workflow-log', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="workflow-log" data-app-id={appDetail.id} />
),
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: ':icon:',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
describe('LogAnnotation', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
})
// Rendering behavior
describe('Rendering', () => {
it('should render loading state when app detail is missing', () => {
// Arrange
useAppStore.setState({ appDetail: undefined })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render log and annotation tabs for non-completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByText('appLog.title')).toBeInTheDocument()
expect(screen.getByText('appAnnotation.title')).toBeInTheDocument()
})
it('should render only log tab for completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByText('appLog.title')).toBeInTheDocument()
expect(screen.queryByText('appAnnotation.title')).not.toBeInTheDocument()
})
it('should hide tabs and render workflow log in workflow mode', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.queryByText('appLog.title')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-log')).toBeInTheDocument()
})
})
// Prop-driven behavior
describe('Props', () => {
it('should render log content when page type is log', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByTestId('log')).toBeInTheDocument()
expect(screen.queryByTestId('annotation')).not.toBeInTheDocument()
})
it('should render annotation content when page type is annotation', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
// Act
render(<LogAnnotation pageType={PageType.annotation} />)
// Assert
expect(screen.getByTestId('annotation')).toBeInTheDocument()
expect(screen.queryByTestId('log')).not.toBeInTheDocument()
})
})
// User interaction behavior
describe('User Interactions', () => {
it('should navigate to annotations when switching from log tab', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<LogAnnotation pageType={PageType.log} />)
await user.click(screen.getByText('appAnnotation.title'))
// Assert
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/annotations')
})
it('should navigate to logs when switching from annotation tab', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<LogAnnotation pageType={PageType.annotation} />)
await user.click(screen.getByText('appLog.title'))
// Assert
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/logs')
})
})
})

View File

@ -6,11 +6,10 @@ import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import Chip from '@/app/components/base/chip'
import Input from '@/app/components/base/input'
import Sort from '@/app/components/base/sort'
import { fetchAnnotationsCount } from '@/service/log'
import { useAnnotationsCount } from '@/service/use-log'
dayjs.extend(quarterOfYear)
@ -36,9 +35,9 @@ type IFilterProps = {
}
const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryParams }: IFilterProps) => {
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
const { data, isLoading } = useAnnotationsCount(appId)
const { t } = useTranslation()
if (!data)
if (isLoading || !data)
return null
return (
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
@ -51,7 +50,7 @@ const Filter: FC<IFilterProps> = ({ isChatMode, appId, queryParams, setQueryPara
setQueryParams({ ...queryParams, period: item.value })
}}
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}` as any) as string }))}
/>
<Chip
className="min-w-[150px]"

View File

@ -3,16 +3,15 @@ import type { FC } from 'react'
import type { App } from '@/types/app'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'lodash-es'
import { omit } from 'es-toolkit/compat'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { fetchChatConversations, fetchCompletionConversations } from '@/service/log'
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import EmptyElement from './empty-element'
import Filter, { TIME_PERIOD_MAPPING } from './filter'
@ -88,19 +87,15 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
}
// When the details are obtained, proceed to the next request
const { data: chatConversations, mutate: mutateChatList } = useSWR(() => isChatMode
? {
url: `/apps/${appDetail.id}/chat-conversations`,
params: query,
}
: null, fetchChatConversations)
const { data: chatConversations, refetch: mutateChatList } = useChatConversations({
appId: isChatMode ? appDetail.id : '',
params: query,
})
const { data: completionConversations, mutate: mutateCompletionList } = useSWR(() => !isChatMode
? {
url: `/apps/${appDetail.id}/completion-conversations`,
params: query,
}
: null, fetchCompletionConversations)
const { data: completionConversations, refetch: mutateCompletionList } = useCompletionConversations({
appId: !isChatMode ? appDetail.id : '',
params: query,
})
const total = isChatMode ? chatConversations?.total : completionConversations?.total

View File

@ -12,12 +12,11 @@ import { RiCloseLine, RiEditFill } from '@remixicon/react'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { get, noop } from 'lodash-es'
import { get, noop } from 'es-toolkit/compat'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { createContext, useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import ModelInfo from '@/app/components/app/log/model-info'
@ -38,7 +37,8 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { fetchChatConversationDetail, fetchChatMessages, fetchCompletionConversationDetail, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import PromptLogModal from '../../base/prompt-log-modal'
@ -825,8 +825,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
*/
const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
// Text Generator App Session Details Including Message List
const detailParams = ({ url: `/apps/${appId}/completion-conversations/${conversationId}` })
const { data: conversationDetail, mutate: conversationDetailMutate } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchCompletionConversationDetail)
const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId)
const { notify } = useContext(ToastContext)
const { t } = useTranslation()
@ -875,8 +874,7 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
* Chat App Conversation Detail Component
*/
const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
const detailParams = { url: `/apps/${appId}/chat-conversations/${conversationId}` }
const { data: conversationDetail } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchChatConversationDetail)
const { data: conversationDetail } = useChatConversationDetail(appId, conversationId)
const { notify } = useContext(ToastContext)
const { t } = useTranslation()

View File

@ -2,7 +2,7 @@ import type { RenderOptions } from '@testing-library/react'
import type { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render } from '@testing-library/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { defaultPlan } from '@/app/components/billing/config'
import { useModalContext as actualUseModalContext } from '@/context/modal-context'

View File

@ -6,7 +6,7 @@ import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyM
import dayjs from 'dayjs'
import Decimal from 'decimal.js'
import ReactECharts from 'echarts-for-react'
import { get } from 'lodash-es'
import { get } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Basic from '@/app/components/app-sidebar/basic'

View File

@ -2,7 +2,7 @@
import type { App } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -55,7 +55,7 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
setQueryParams({ ...queryParams, period: item.value })
}}
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}` as any) as string }))}
/>
<Input
wrapperClassName="w-[200px]"

View File

@ -1,9 +1,9 @@
import type { MockedFunction } from 'vitest'
import type { UseQueryResult } from '@tanstack/react-query'
/**
* Logs Container Component Tests
*
* Tests the main Logs container component which:
* - Fetches workflow logs via useSWR
* - Fetches workflow logs via TanStack Query
* - Manages query parameters (status, period, keyword)
* - Handles pagination
* - Renders Filter, List, and Empty states
@ -15,14 +15,16 @@ import type { MockedFunction } from 'vitest'
* - trigger-by-display.spec.tsx
*/
import type { MockedFunction } from 'vitest'
import type { ILogsProps } from './index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useSWR from 'swr'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
import { TIME_PERIOD_MAPPING } from './filter'
import Logs from './index'
@ -30,7 +32,7 @@ import Logs from './index'
// Mocks
// ============================================================================
vi.mock('swr')
vi.mock('@/service/use-log')
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
@ -72,10 +74,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/service/log', () => ({
fetchWorkflowLogs: vi.fn(),
}))
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => {
@ -89,38 +87,76 @@ vi.mock('@/context/app-context', () => ({
}),
}))
// Mock useTimestamp
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`,
}),
}))
// Mock useBreakpoints
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'pc',
MediaType: {
mobile: 'mobile',
pc: 'pc',
},
}))
// Mock BlockIcon
vi.mock('@/app/components/workflow/block-icon', () => ({
__esModule: true,
default: () => <div data-testid="block-icon">BlockIcon</div>,
}))
// Mock WorkflowContextProvider
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
<>{children}</>
),
}))
const mockedUseSWR = useSWR as unknown as MockedFunction<typeof useSWR>
const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<typeof useLogModule.useWorkflowLogs>
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// ============================================================================
// Mock Return Value Factory
// ============================================================================
const createMockQueryResult = <T,>(
overrides: { data?: T, isLoading?: boolean, error?: Error | null } = {},
): UseQueryResult<T, Error> => {
const isLoading = overrides.isLoading ?? false
const error = overrides.error ?? null
const data = overrides.data
return {
data,
isLoading,
error,
refetch: vi.fn(),
isError: !!error,
isPending: isLoading,
isSuccess: !isLoading && !error && data !== undefined,
isFetching: isLoading,
isRefetching: false,
isLoadingError: false,
isRefetchError: false,
isInitialLoading: isLoading,
isPaused: false,
isEnabled: true,
status: isLoading ? 'pending' : error ? 'error' : 'success',
fetchStatus: isLoading ? 'fetching' : 'idle',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: !isLoading,
isFetchedAfterMount: !isLoading,
isPlaceholderData: false,
isStale: false,
promise: Promise.resolve(data as T),
} as UseQueryResult<T, Error>
}
// ============================================================================
// Test Data Factories
@ -195,6 +231,20 @@ const createMockLogsResponse = (
page: 1,
})
// ============================================================================
// Type-safe Mock Helper
// ============================================================================
type WorkflowLogsParams = {
appId: string
params?: Record<string, string | number | boolean | undefined>
}
const getMockCallParams = (): WorkflowLogsParams | undefined => {
const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1)
return lastCall?.[0]
}
// ============================================================================
// Tests
// ============================================================================
@ -213,45 +263,48 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
})
it('should render title and subtitle', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('appLog.workflowTitle')).toBeInTheDocument()
expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument()
})
it('should render Filter component', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
})
})
@ -261,30 +314,33 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading spinner when data is undefined', () => {
mockedUseSWR.mockReturnValue({
data: undefined,
mutate: vi.fn(),
isValidating: true,
isLoading: true,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: undefined,
isLoading: true,
}),
)
const { container } = render(<Logs {...defaultProps} />)
// Act
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
it('should not show loading spinner when data is available', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
const { container } = render(<Logs {...defaultProps} />)
// Act
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
})
})
@ -294,16 +350,17 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('Empty State', () => {
it('should render empty element when total is 0', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
@ -313,20 +370,21 @@ describe('Logs Container', () => {
// Data Fetching Tests
// --------------------------------------------------------------------------
describe('Data Fetching', () => {
it('should call useSWR with correct URL and default params', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
it('should call useWorkflowLogs with correct appId and default params', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string, params: Record<string, unknown> }
expect(keyArg).toMatchObject({
url: `/apps/${defaultProps.appDetail.id}/workflow-app-logs`,
// Assert
const callArg = getMockCallParams()
expect(callArg).toMatchObject({
appId: defaultProps.appDetail.id,
params: expect.objectContaining({
page: 1,
detail: true,
@ -336,34 +394,36 @@ describe('Logs Container', () => {
})
it('should include date filters for non-allTime periods', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
expect(keyArg?.params).toHaveProperty('created_at__after')
expect(keyArg?.params).toHaveProperty('created_at__before')
// Assert
const callArg = getMockCallParams()
expect(callArg?.params).toHaveProperty('created_at__after')
expect(callArg?.params).toHaveProperty('created_at__before')
})
it('should not include status param when status is all', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
expect(keyArg?.params).not.toHaveProperty('status')
// Assert
const callArg = getMockCallParams()
expect(callArg?.params).not.toHaveProperty('status')
})
})
@ -372,24 +432,23 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('Filter Integration', () => {
it('should update query when selecting status filter', async () => {
// Arrange
const user = userEvent.setup()
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
renderWithQueryClient(<Logs {...defaultProps} />)
// Click status filter
// Act
await user.click(screen.getByText('All'))
await user.click(await screen.findByText('Success'))
// Check that useSWR was called with updated params
// Assert
await waitFor(() => {
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
const lastCall = getMockCallParams()
expect(lastCall?.params).toMatchObject({
status: 'succeeded',
})
@ -397,46 +456,46 @@ describe('Logs Container', () => {
})
it('should update query when selecting period filter', async () => {
// Arrange
const user = userEvent.setup()
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
renderWithQueryClient(<Logs {...defaultProps} />)
// Click period filter
// Act
await user.click(screen.getByText('appLog.filter.period.last7days'))
await user.click(await screen.findByText('appLog.filter.period.allTime'))
// When period is allTime (9), date filters should be removed
// Assert
await waitFor(() => {
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
const lastCall = getMockCallParams()
expect(lastCall?.params).not.toHaveProperty('created_at__after')
expect(lastCall?.params).not.toHaveProperty('created_at__before')
})
})
it('should update query when typing keyword', async () => {
// Arrange
const user = userEvent.setup()
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
render(<Logs {...defaultProps} />)
renderWithQueryClient(<Logs {...defaultProps} />)
// Act
const searchInput = screen.getByPlaceholderText('common.operation.search')
await user.type(searchInput, 'test-keyword')
// Assert
await waitFor(() => {
const lastCall = mockedUseSWR.mock.calls.at(-1)?.[0] as { params?: Record<string, unknown> }
const lastCall = getMockCallParams()
expect(lastCall?.params).toMatchObject({
keyword: 'test-keyword',
})
@ -449,36 +508,35 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('Pagination', () => {
it('should not render pagination when total is less than limit', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Pagination component should not be rendered
// Assert
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('should render pagination when total exceeds limit', () => {
// Arrange
const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) =>
createMockWorkflowLog({ id: `log-${i}` }))
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Should show pagination - checking for any pagination-related element
// The Pagination component renders page controls
// Assert
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
@ -488,37 +546,39 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('List Rendering', () => {
it('should render List component when data is available', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should display log data in table', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
status: 'succeeded',
total_tokens: 500,
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([
createMockWorkflowLog({
workflow_run: createMockWorkflowRun({
status: 'succeeded',
total_tokens: 500,
}),
}),
}),
], 1),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
], 1),
}),
)
render(<Logs {...defaultProps} />)
// Act
renderWithQueryClient(<Logs {...defaultProps} />)
// Assert
expect(screen.getByText('Success')).toBeInTheDocument()
expect(screen.getByText('500')).toBeInTheDocument()
})
@ -541,52 +601,54 @@ describe('Logs Container', () => {
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle different app modes', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum })
render(<Logs appDetail={chatApp} />)
// Act
renderWithQueryClient(<Logs appDetail={chatApp} />)
// Should render without trigger column
// Assert
expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument()
})
it('should handle error state from useSWR', () => {
mockedUseSWR.mockReturnValue({
data: undefined,
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: new Error('Failed to fetch'),
})
it('should handle error state from useWorkflowLogs', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: undefined,
error: new Error('Failed to fetch'),
}),
)
const { container } = render(<Logs {...defaultProps} />)
// Act
const { container } = renderWithQueryClient(<Logs {...defaultProps} />)
// Should show loading state when data is undefined
// Assert - should show loading state when data is undefined
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
it('should handle app with different ID', () => {
mockedUseSWR.mockReturnValue({
data: createMockLogsResponse([], 0),
mutate: vi.fn(),
isValidating: false,
isLoading: false,
error: undefined,
})
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
const customApp = createMockApp({ id: 'custom-app-123' })
render(<Logs appDetail={customApp} />)
// Act
renderWithQueryClient(<Logs appDetail={customApp} />)
const keyArg = mockedUseSWR.mock.calls.at(-1)?.[0] as { url: string }
expect(keyArg?.url).toBe('/apps/custom-app-123/workflow-app-logs')
// Assert
const callArg = getMockCallParams()
expect(callArg?.appId).toBe('custom-app-123')
})
})
})

View File

@ -5,17 +5,16 @@ import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { omit } from 'lodash-es'
import { omit } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import EmptyElement from '@/app/components/app/log/empty-element'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { useAppContext } from '@/context/app-context'
import { fetchWorkflowLogs } from '@/service/log'
import { useWorkflowLogs } from '@/service/use-log'
import Filter, { TIME_PERIOD_MAPPING } from './filter'
import List from './list'
@ -55,10 +54,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
...omit(debouncedQueryParams, ['period', 'status']),
}
const { data: workflowLogs, mutate } = useSWR({
url: `/apps/${appDetail.id}/workflow-app-logs`,
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
appId: appDetail.id,
params: query,
}, fetchWorkflowLogs)
})
const total = workflowLogs?.total
return (

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { flatten, uniq } from 'lodash-es'
import { flatten, uniq } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -3,7 +3,7 @@ import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { RiImageCircleAiLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'

View File

@ -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}`)
})
})
})
})

View File

@ -94,7 +94,7 @@ const BlockInput: FC<IBlockInputProps> = ({
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
message: t(`appDebug.varKeyError.${errorMessageKey}` as any, { key: errorKey }) as string,
})
return
}

View File

@ -1,5 +1,5 @@
import type { ChatItemInTree } from '../types'
import { get } from 'lodash-es'
import { get } from 'es-toolkit/compat'
import { buildChatItemTree, getThreadMessages } from '../utils'
import branchedTestMessages from './branchedTestMessages.json'
import legacyTestMessages from './legacyTestMessages.json'

View File

@ -14,7 +14,7 @@ import type {
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type ChatWithHistoryContextValue = {

View File

@ -10,8 +10,8 @@ import type {
ConversationItem,
} from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import {
useCallback,
useEffect,

View File

@ -8,8 +8,8 @@ import type { InputForm } from './type'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Annotation } from '@/models/log'
import { noop, uniqBy } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
import { noop, uniqBy } from 'lodash-es'
import { useParams, usePathname } from 'next/navigation'
import {
useCallback,

View File

@ -13,7 +13,7 @@ import type {
import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { AppData } from '@/models/share'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import {
memo,
useCallback,

View File

@ -13,7 +13,7 @@ import type {
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type EmbeddedChatbotContextValue = {

View File

@ -3,13 +3,14 @@ import type {
ChatItem,
Feedback,
} from '../types'
import type { Locale } from '@/i18n-config'
import type {
// AppData,
ConversationItem,
} from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import {
useCallback,
useEffect,
@ -93,7 +94,7 @@ export const useEmbeddedChatbot = () => {
if (localeParam) {
// If locale parameter exists in URL, use it instead of default
await changeLanguage(localeParam)
await changeLanguage(localeParam as Locale)
}
else if (localeFromSysVar) {
// If locale is set as a system variable, use that

View File

@ -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])
})
})
})

View File

@ -4,7 +4,7 @@ import {
RiClipboardLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,6 +1,6 @@
'use client'
import copy from 'copy-to-clipboard'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -6,7 +6,7 @@ const YEAR_RANGE = 100
export const useDaysOfWeek = () => {
const { t } = useTranslation()
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => t(`time.daysInWeek.${day}`))
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => t(`time.daysInWeek.${day}` as any) as string)
return daysOfWeek
}
@ -26,7 +26,7 @@ export const useMonths = () => {
'October',
'November',
'December',
].map(month => t(`time.months.${month}`))
].map(month => t(`time.months.${month}` as any) as string)
return months
}

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -16,7 +16,7 @@ export const EncryptedBottom = (props: Props) => {
return (
<div className={cn('system-xs-regular flex items-center justify-center rounded-b-2xl border-t-[0.5px] border-divider-subtle bg-background-soft px-2 py-3 text-text-tertiary', className)}>
<RiLock2Fill className="mx-1 h-3 w-3 text-text-quaternary" />
{t(frontTextKey || 'common.provider.encrypted.front')}
{t((frontTextKey || 'common.provider.encrypted.front') as any) as string}
<Link
className="mx-1 text-text-accent"
target="_blank"
@ -25,7 +25,7 @@ export const EncryptedBottom = (props: Props) => {
>
PKCS1_OAEP
</Link>
{t(backTextKey || 'common.provider.encrypted.back')}
{t((backTextKey || 'common.provider.encrypted.back') as any) as string}
</div>
)
}

View File

@ -3,8 +3,8 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -2,7 +2,7 @@ import type { ChangeEvent, FC } from 'react'
import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@ -97,7 +97,7 @@ const VoiceParamConfig = ({
className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pl-3 pr-10 focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6"
>
<span className={cn('block truncate text-left text-text-secondary', !languageItem?.name && 'text-text-tertiary')}>
{languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}`) : localLanguagePlaceholder}
{languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}` as any) as string : localLanguagePlaceholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
@ -128,7 +128,7 @@ const VoiceParamConfig = ({
<span
className={cn('block', selected && 'font-normal')}
>
{t(`common.voice.language.${(item.value).toString().replace('-', '')}`)}
{t(`common.voice.language.${(item.value).toString().replace('-', '')}` as any) as string}
</span>
{(selected || item.value === text2speech?.language) && (
<span

View File

@ -2,8 +2,8 @@ import type { ClipboardEvent } from 'react'
import type { FileEntity } from './types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import { useParams } from 'next/navigation'
import {
useCallback,
@ -174,7 +174,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any)
notify({ type: 'error', message: errorMessage })
handleUpdateFile({ ...uploadingFile, progress: -1 })
},
@ -287,7 +287,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any)
notify({ type: 'error', message: errorMessage })
handleUpdateFile({ ...uploadingFile, progress: -1 })
},

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { t } from 'i18next'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'

View File

@ -1,7 +1,7 @@
import type {
FileEntity,
} from './types'
import { isEqual } from 'lodash-es'
import { isEqual } from 'es-toolkit/compat'
import {
createContext,
useContext,

View File

@ -44,7 +44,7 @@ export const useInputTypeOptions = (supportFile: boolean) => {
return options.map((value) => {
return {
value,
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`),
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}` as any),
Icon: INPUT_TYPE_ICON[value],
type: DATA_TYPE[value],
}

View File

@ -1,6 +1,6 @@
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLargeLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { cn } from '@/utils/classnames'
type IModal = {

View File

@ -2,7 +2,7 @@ import { access, appendFile, mkdir, open, readdir, rm, writeFile } from 'node:fs
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseXml } from '@rgrove/parse-xml'
import { camelCase, template } from 'lodash-es'
import { camelCase, template } from 'es-toolkit/compat'
const __dirname = path.dirname(fileURLToPath(import.meta.url))

View File

@ -82,7 +82,7 @@ export const useImageFiles = () => {
setFiles(newFiles)
},
onErrorCallback: (error?: any) => {
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t as any)
notify({ type: 'error', message: errorMessage })
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
@ -160,7 +160,7 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
onUpload({ ...imageFile, fileId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t as any)
notify({ type: 'error', message: errorMessage })
onUpload({ ...imageFile, progress: -1 })
},

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { t } from 'i18next'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'

View File

@ -25,8 +25,8 @@ vi.mock('react-i18next', () => ({
}),
}))
// Mock lodash-es debounce
vi.mock('lodash-es', () => ({
// Mock es-toolkit/compat debounce
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: any) => fn,
}))

View File

@ -2,7 +2,7 @@
import type { InputProps } from '../input'
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

Some files were not shown because too many files have changed in this diff Show More