mirror of https://github.com/langgenius/dify.git
Merge branch 'main' of github.com:langgenius/dify into feat/log-formatter
This commit is contained in:
commit
17e9eb30eb
|
|
@ -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 │
|
||||
└────────────────────────────────────────┘
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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__])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -122,14 +122,12 @@ def _inject_trace_headers(headers: dict | None) -> dict:
|
|||
|
||||
|
||||
def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs):
|
||||
# Convert requests-style allow_redirects to httpx-style follow_redirects
|
||||
if "allow_redirects" in kwargs:
|
||||
allow_redirects = kwargs.pop("allow_redirects")
|
||||
if "follow_redirects" not in kwargs:
|
||||
kwargs["follow_redirects"] = allow_redirects
|
||||
|
||||
# Extract follow_redirects - it's a send() parameter, not build_request() parameter
|
||||
follow_redirects = kwargs.pop("follow_redirects", False)
|
||||
|
||||
if "timeout" not in kwargs:
|
||||
kwargs["timeout"] = httpx.Timeout(
|
||||
timeout=dify_config.SSRF_DEFAULT_TIME_OUT,
|
||||
|
|
@ -155,16 +153,13 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs):
|
|||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
# 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
|
||||
# Preserve the user-provided Host header
|
||||
# httpx may override the Host header when using a proxy
|
||||
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, follow_redirects=follow_redirects)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,25 +12,22 @@ 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")
|
||||
assert response.status_code == 200
|
||||
mock_client.request.assert_called_once()
|
||||
|
||||
|
||||
@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 +35,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."""
|
||||
|
||||
|
|
@ -102,159 +71,106 @@ class TestGetUserProvidedHostHeader:
|
|||
assert result in ("first.com", "second.com")
|
||||
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_host_header_preservation_without_user_header(mock_get_client):
|
||||
"""Test that when no Host header is provided, the default behavior is maintained."""
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_host_header_preservation_with_user_header(mock_get_client):
|
||||
"""Test that user-provided Host header is preserved in the request."""
|
||||
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_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, follow_redirects=False)
|
||||
# Verify client.request was called with the host header preserved (lowercase)
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs["headers"]["host"] == custom_host
|
||||
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
@pytest.mark.parametrize("host_key", ["host", "HOST"])
|
||||
@pytest.mark.parametrize("host_key", ["host", "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_client.request.return_value = mock_response
|
||||
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"
|
||||
|
||||
assert response.status_code == 200
|
||||
# Host header should be normalized to lowercase "host"
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs["headers"]["host"] == "api.example.com"
|
||||
|
||||
|
||||
class TestFollowRedirectsParameter:
|
||||
"""Tests for follow_redirects parameter handling.
|
||||
|
||||
These tests verify that follow_redirects is passed to send(), not build_request().
|
||||
This is critical because httpx.Client.build_request() does not accept follow_redirects.
|
||||
These tests verify that follow_redirects is correctly passed to client.request().
|
||||
"""
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_follow_redirects_not_passed_to_build_request(self, mock_get_client):
|
||||
"""Verify follow_redirects is NOT passed to build_request()."""
|
||||
def test_follow_redirects_passed_to_request(self, mock_get_client):
|
||||
"""Verify follow_redirects IS passed to client.request()."""
|
||||
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_client.request.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
make_request("GET", "http://example.com", follow_redirects=True)
|
||||
|
||||
# Verify follow_redirects was NOT passed to build_request
|
||||
call_kwargs = mock_client.build_request.call_args.kwargs
|
||||
assert "follow_redirects" not in call_kwargs, "follow_redirects should not be passed to build_request()"
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_follow_redirects_passed_to_send(self, mock_get_client):
|
||||
"""Verify follow_redirects IS passed to send()."""
|
||||
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
|
||||
|
||||
make_request("GET", "http://example.com", follow_redirects=True)
|
||||
|
||||
# Verify follow_redirects WAS passed to send
|
||||
mock_client.send.assert_called_once_with(mock_request, follow_redirects=True)
|
||||
# Verify follow_redirects was passed to request
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_allow_redirects_converted_to_follow_redirects(self, mock_get_client):
|
||||
"""Verify allow_redirects (requests-style) is converted to follow_redirects (httpx-style)."""
|
||||
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_client.request.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
# Use allow_redirects (requests-style parameter)
|
||||
make_request("GET", "http://example.com", allow_redirects=True)
|
||||
|
||||
# Verify it was converted to follow_redirects for send()
|
||||
mock_client.send.assert_called_once_with(mock_request, follow_redirects=True)
|
||||
# Verify allow_redirects was NOT passed to build_request
|
||||
call_kwargs = mock_client.build_request.call_args.kwargs
|
||||
# Verify it was converted to follow_redirects
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
assert "allow_redirects" not in call_kwargs
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_follow_redirects_default_is_false(self, mock_get_client):
|
||||
"""Verify follow_redirects defaults to False when not specified."""
|
||||
def test_follow_redirects_not_set_when_not_specified(self, mock_get_client):
|
||||
"""Verify follow_redirects is not in kwargs when not specified (httpx default behavior)."""
|
||||
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_client.request.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
make_request("GET", "http://example.com")
|
||||
|
||||
# Verify default is False
|
||||
mock_client.send.assert_called_once_with(mock_request, follow_redirects=False)
|
||||
# follow_redirects should not be in kwargs, letting httpx use its default
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert "follow_redirects" not in call_kwargs
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client")
|
||||
def test_follow_redirects_takes_precedence_over_allow_redirects(self, mock_get_client):
|
||||
"""Verify follow_redirects takes precedence when both are specified."""
|
||||
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_client.request.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
# Both specified - follow_redirects should take precedence
|
||||
make_request("GET", "http://example.com", allow_redirects=False, follow_redirects=True)
|
||||
|
||||
mock_client.send.assert_called_once_with(mock_request, follow_redirects=True)
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ import { omit } from 'lodash-es'
|
|||
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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
ChatItem,
|
||||
Feedback,
|
||||
} from '../types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import type {
|
||||
// AppData,
|
||||
ConversationItem,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|||
{...props}
|
||||
/>
|
||||
{showClearIcon && value && !disabled && !destructive && (
|
||||
<div className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')} onClick={onClear}>
|
||||
<div
|
||||
className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')}
|
||||
onClick={onClear}
|
||||
data-testid="input-clear"
|
||||
>
|
||||
<RiCloseCircleFill className="h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export const SkeletonPoint: FC<SkeletonProps> = (props) => {
|
|||
<div className={cn('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
|
||||
)
|
||||
}
|
||||
/** Usage
|
||||
/**
|
||||
* Usage
|
||||
* <SkeletonContainer>
|
||||
* <SkeletonRow>
|
||||
* <SkeletonRectangle className="w-96" />
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ const TagInput: FC<TagInputProps> = ({
|
|||
setValue(e.target.value)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t(placeholder || (isSpecialMode ? 'common.model.params.stop_sequencesPlaceholder' : 'datasetDocuments.segment.addKeyWord'))}
|
||||
placeholder={t((placeholder || (isSpecialMode ? 'common.model.params.stop_sequencesPlaceholder' : 'datasetDocuments.segment.addKeyWord')) as any)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import AppsFull from './index'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: vi.fn(),
|
||||
}))
|
||||
|
||||
const buildUsage = (overrides: Partial<UsagePlanInfo> = {}): UsagePlanInfo => ({
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const buildProviderContext = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => ({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage({ buildApps: 2 }),
|
||||
total: buildUsage({ buildApps: 10 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const buildAppContext = (overrides: Partial<AppContextValue> = {}): AppContextValue => {
|
||||
const userProfile: UserProfileResponse = {
|
||||
id: 'user-id',
|
||||
name: 'Test User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: false,
|
||||
}
|
||||
const currentWorkspace: ICurrentWorkspace = {
|
||||
id: 'workspace-id',
|
||||
name: 'Workspace',
|
||||
plan: '',
|
||||
status: '',
|
||||
created_at: 0,
|
||||
role: 'normal',
|
||||
providers: [],
|
||||
}
|
||||
const langGeniusVersionInfo: LangGeniusVersionResponse = {
|
||||
current_env: '',
|
||||
current_version: '1.0.0',
|
||||
latest_version: '',
|
||||
release_date: '',
|
||||
release_notes: '',
|
||||
version: '',
|
||||
can_auto_update: false,
|
||||
}
|
||||
const base: Omit<AppContextValue, 'useSelector'> = {
|
||||
userProfile,
|
||||
currentWorkspace,
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateUserProfile: vi.fn(),
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
}
|
||||
const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector })
|
||||
return {
|
||||
...base,
|
||||
useSelector,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppsFull', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext())
|
||||
;(useAppContext as Mock).mockReturnValue(buildAppContext())
|
||||
;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com')
|
||||
})
|
||||
|
||||
// Rendering behavior for non-team plans.
|
||||
describe('Rendering', () => {
|
||||
it('should render the sandbox messaging and upgrade button', () => {
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
expect(screen.getByText('2/10')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior for team plans and contact CTA.
|
||||
describe('Props', () => {
|
||||
it('should render team messaging and contact button for non-sandbox plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.team,
|
||||
usage: buildUsage({ buildApps: 8 }),
|
||||
total: buildUsage({ buildApps: 10 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0')
|
||||
})
|
||||
|
||||
it('should render upgrade button for professional plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.professional,
|
||||
usage: buildUsage({ buildApps: 4 }),
|
||||
total: buildUsage({ buildApps: 10 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render contact button for enterprise plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.enterprise,
|
||||
usage: buildUsage({ buildApps: 9 }),
|
||||
total: buildUsage({ buildApps: 10 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.enterprise, '1.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases for progress color thresholds.
|
||||
describe('Edge Cases', () => {
|
||||
it('should use the success color when usage is below 50%', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage({ buildApps: 2 }),
|
||||
total: buildUsage({ buildApps: 5 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
|
||||
})
|
||||
|
||||
it('should use the warning color when usage is between 50% and 80%', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage({ buildApps: 6 }),
|
||||
total: buildUsage({ buildApps: 10 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
|
||||
})
|
||||
|
||||
it('should use the error color when usage is 80% or higher', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage({ buildApps: 8 }),
|
||||
total: buildUsage({ buildApps: 10 }),
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import {
|
||||
Cloud,
|
||||
Community,
|
||||
Enterprise,
|
||||
EnterpriseNoise,
|
||||
NoiseBottom,
|
||||
NoiseTop,
|
||||
Premium,
|
||||
PremiumNoise,
|
||||
Professional,
|
||||
Sandbox,
|
||||
SelfHosted,
|
||||
Team,
|
||||
} from './index'
|
||||
|
||||
describe('Pricing Assets', () => {
|
||||
// Rendering: each asset should render an svg.
|
||||
describe('Rendering', () => {
|
||||
it('should render static assets without crashing', () => {
|
||||
// Arrange
|
||||
const assets = [
|
||||
<Community key="community" />,
|
||||
<Enterprise key="enterprise" />,
|
||||
<EnterpriseNoise key="enterprise-noise" />,
|
||||
<NoiseBottom key="noise-bottom" />,
|
||||
<NoiseTop key="noise-top" />,
|
||||
<Premium key="premium" />,
|
||||
<PremiumNoise key="premium-noise" />,
|
||||
<Professional key="professional" />,
|
||||
<Sandbox key="sandbox" />,
|
||||
<Team key="team" />,
|
||||
]
|
||||
|
||||
// Act / Assert
|
||||
assets.forEach((asset) => {
|
||||
const { container, unmount } = render(asset)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Props: active state should change fill color for selectable assets.
|
||||
describe('Props', () => {
|
||||
it('should render active state for Cloud', () => {
|
||||
// Arrange
|
||||
const { container } = render(<Cloud isActive />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render inactive state for SelfHosted', () => {
|
||||
// Arrange
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '.'
|
||||
import Footer from './footer'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
<a href={href} className={className} target={target} data-testid="pricing-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => mockTranslations[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render tax tips and comparison link when in cloud category', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
|
||||
expect(screen.getByText('billing.plansCommon.comparePlanAndFeatures')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should hide tax tips when category is self-hosted', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render link even when pricing URL is empty', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="" currentCategory={CategoryEnum.CLOUD} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from './header'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => mockTranslations[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render title and description translations', () => {
|
||||
// Arrange
|
||||
const handleClose = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Header onClose={handleClose} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should invoke onClose when close button is clicked', () => {
|
||||
// Arrange
|
||||
const handleClose = vi.fn()
|
||||
render(<Header onClose={handleClose} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render structure when translations are empty strings', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.title.plans': '',
|
||||
'billing.plansCommon.title.description': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<Header onClose={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('span')).toBeInTheDocument()
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../type'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../type'
|
||||
import Pricing from './index'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
let mockLanguage: string | null = 'en'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
<a href={href} className={className} target={target} data-testid="pricing-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { returnObjects?: boolean }) => {
|
||||
if (options?.returnObjects)
|
||||
return mockTranslations[key] ?? []
|
||||
return mockTranslations[key] ?? key
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}
|
||||
})
|
||||
|
||||
const buildUsage = (): UsagePlanInfo => ({
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
})
|
||||
|
||||
describe('Pricing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
mockLanguage = 'en'
|
||||
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage(),
|
||||
total: buildUsage(),
|
||||
},
|
||||
})
|
||||
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render pricing header and localized footer link', () => {
|
||||
// Arrange
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should register esc key handler and allow switching categories', () => {
|
||||
// Arrange
|
||||
const handleCancel = vi.fn()
|
||||
render(<Pricing onCancel={handleCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
|
||||
// Assert
|
||||
expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should fall back to default pricing URL when language is empty', () => {
|
||||
// Arrange
|
||||
mockLanguage = ''
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '../index'
|
||||
import PlanSwitcher from './index'
|
||||
import { PlanRange } from './plan-range-switcher'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => mockTranslations[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlanSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render category tabs and plan range switcher for cloud', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.CLOUD}
|
||||
currentPlanRange={PlanRange.monthly}
|
||||
onChangeCategory={vi.fn()}
|
||||
onChangePlanRange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should call onChangeCategory when selecting a tab', () => {
|
||||
// Arrange
|
||||
const handleChangeCategory = vi.fn()
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.CLOUD}
|
||||
currentPlanRange={PlanRange.monthly}
|
||||
onChangeCategory={handleChangeCategory}
|
||||
onChangePlanRange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
|
||||
// Assert
|
||||
expect(handleChangeCategory).toHaveBeenCalledTimes(1)
|
||||
expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF)
|
||||
})
|
||||
|
||||
it('should hide plan range switcher when category is self-hosted', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
currentPlanRange={PlanRange.yearly}
|
||||
onChangeCategory={vi.fn()}
|
||||
onChangePlanRange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render tabs when translation strings are empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.cloud': '',
|
||||
'billing.plansCommon.self': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
currentPlanRange={PlanRange.monthly}
|
||||
onChangeCategory={vi.fn()}
|
||||
onChangePlanRange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labels = container.querySelectorAll('span')
|
||||
expect(labels).toHaveLength(2)
|
||||
expect(labels[0]?.textContent).toBe('')
|
||||
expect(labels[1]?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => mockTranslations[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlanRangeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render the annual billing label', () => {
|
||||
// Arrange
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should switch to yearly when toggled from monthly', () => {
|
||||
// Arrange
|
||||
const handleChange = vi.fn()
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={handleChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly)
|
||||
})
|
||||
|
||||
it('should switch to monthly when toggled from yearly', () => {
|
||||
// Arrange
|
||||
const handleChange = vi.fn()
|
||||
render(<PlanRangeSwitcher value={PlanRange.yearly} onChange={handleChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render when the translation string is empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.annualBilling': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Tab from './tab'
|
||||
|
||||
const Icon = ({ isActive }: { isActive: boolean }) => (
|
||||
<svg data-testid="tab-icon" data-active={isActive ? 'true' : 'false'} />
|
||||
)
|
||||
|
||||
describe('PlanSwitcherTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render label and icon', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
value="cloud"
|
||||
label="Cloud"
|
||||
isActive={false}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Cloud')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should call onClick with the provided value', () => {
|
||||
// Arrange
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
value="self"
|
||||
label="Self"
|
||||
isActive={false}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('Self'))
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(handleClick).toHaveBeenCalledWith('self')
|
||||
})
|
||||
|
||||
it('should apply active text class when isActive is true', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
value="cloud"
|
||||
label="Cloud"
|
||||
isActive
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
|
||||
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render when label is empty', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
value="cloud"
|
||||
label=""
|
||||
isActive={false}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -106,7 +106,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
{ICON_MAP[plan]}
|
||||
<div className="flex min-h-[104px] flex-col gap-y-2">
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
<div className="text-[30px] font-medium leading-[1.2] text-text-primary">{t(`${i18nPrefix}.name`)}</div>
|
||||
<div className="text-[30px] font-medium leading-[1.2] text-text-primary">{t(`${i18nPrefix}.name` as any) as string}</div>
|
||||
{
|
||||
isMostPopularPlan && (
|
||||
<div className="flex items-center justify-center bg-saas-dify-blue-static px-1.5 py-1">
|
||||
|
|
@ -117,7 +117,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
)
|
||||
}
|
||||
</div>
|
||||
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.description`)}</div>
|
||||
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.description` as any) as string}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Price */}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const Button = ({
|
|||
onClick={handleGetPayUrl}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span>{t(`${i18nPrefix}.btnText`)}</span>
|
||||
<span>{t(`${i18nPrefix}.btnText` as any) as string}</span>
|
||||
{isPremiumPlan && (
|
||||
<span className="pb-px pt-[7px]">
|
||||
<AwsMarketplace className="h-6" />
|
||||
|
|
|
|||
|
|
@ -85,16 +85,16 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({
|
|||
<div className=" flex flex-col gap-y-6 px-1 pt-10">
|
||||
{STYLE_MAP[plan].icon}
|
||||
<div className="flex min-h-[104px] flex-col gap-y-2">
|
||||
<div className="text-[30px] font-medium leading-[1.2] text-text-primary">{t(`${i18nPrefix}.name`)}</div>
|
||||
<div className="system-md-regular line-clamp-2 text-text-secondary">{t(`${i18nPrefix}.description`)}</div>
|
||||
<div className="text-[30px] font-medium leading-[1.2] text-text-primary">{t(`${i18nPrefix}.name` as any) as string}</div>
|
||||
<div className="system-md-regular line-clamp-2 text-text-secondary">{t(`${i18nPrefix}.description` as any) as string}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Price */}
|
||||
<div className="flex items-end gap-x-2 px-1 pb-8 pt-4">
|
||||
<div className="title-4xl-semi-bold shrink-0 text-text-primary">{t(`${i18nPrefix}.price`)}</div>
|
||||
<div className="title-4xl-semi-bold shrink-0 text-text-primary">{t(`${i18nPrefix}.price` as any) as string}</div>
|
||||
{!isFreePlan && (
|
||||
<span className="system-md-regular pb-0.5 text-text-tertiary">
|
||||
{t(`${i18nPrefix}.priceTip`)}
|
||||
{t(`${i18nPrefix}.priceTip` as any) as string}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ const List = ({
|
|||
}: ListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const i18nPrefix = `billing.plans.${plan}`
|
||||
const features = t(`${i18nPrefix}.features`, { returnObjects: true }) as string[]
|
||||
const features = t(`${i18nPrefix}.features` as any, { returnObjects: true }) as unknown as string[]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-[10px] p-6">
|
||||
<div className="system-md-semibold text-text-secondary">
|
||||
<Trans
|
||||
i18nKey={t(`${i18nPrefix}.includesTitle`)}
|
||||
i18nKey={t(`${i18nPrefix}.includesTitle` as any) as string}
|
||||
components={{ highlight: <span className="text-text-warning"></span> }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createMockPlan } from '@/__mocks__/provider-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../type'
|
||||
import PriorityLabel from './index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
const useProviderContextMock = useProviderContext as Mock
|
||||
|
||||
const setupPlan = (planType: Plan) => {
|
||||
useProviderContextMock.mockReturnValue(createMockPlan(planType))
|
||||
}
|
||||
|
||||
describe('PriorityLabel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: basic label output for sandbox plan.
|
||||
describe('Rendering', () => {
|
||||
it('should render the standard priority label when plan is sandbox', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: custom class name applied to the label container.
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to the label container', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
expect(label).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan types: label text and icon visibility for different plans.
|
||||
describe('Plan Types', () => {
|
||||
it('should render priority label and icon when plan is professional', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.professional)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render top priority label and icon when plan is team', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.team)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render standard label without icon when plan is sandbox', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: tooltip content varies by priority level.
|
||||
describe('Edge Cases', () => {
|
||||
it('should show the tip text when priority is not top priority', async () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
|
||||
)).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the tip text when priority is top priority', async () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
|
||||
)).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -31,7 +31,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
|
|||
return (
|
||||
<Tooltip popupContent={(
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-text-primary">{`${t('billing.plansCommon.documentProcessingPriority')}: ${t(`billing.plansCommon.priority.${priority}`)}`}</div>
|
||||
<div className="mb-1 text-xs font-semibold text-text-primary">{`${t('billing.plansCommon.documentProcessingPriority')}: ${t(`billing.plansCommon.priority.${priority}` as any) as string}`}</div>
|
||||
{
|
||||
priority !== DocumentProcessingPriority.topPriority && (
|
||||
<div className="text-xs text-text-secondary">{t('billing.plansCommon.documentProcessingPriorityTip')}</div>
|
||||
|
|
@ -51,7 +51,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
|
|||
<RiAedFill className="mr-0.5 size-3" />
|
||||
)
|
||||
}
|
||||
<span>{t(`billing.plansCommon.priority.${priority}`)}</span>
|
||||
<span>{t(`billing.plansCommon.priority.${priority}` as any) as string}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const ProgressBar = ({
|
|||
return (
|
||||
<div className="overflow-hidden rounded-[6px] bg-components-progress-bar-bg">
|
||||
<div
|
||||
data-testid="billing-progress-bar"
|
||||
className={cn('h-1 rounded-[6px]', color)}
|
||||
style={{
|
||||
width: `${Math.min(percent, 100)}%`,
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ const UpgradeBtn: FC<Props> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)
|
||||
const label = labelKey ? t(labelKey) : defaultBadgeLabel
|
||||
const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}` as any) as string
|
||||
const label = labelKey ? t(labelKey as any) as string : defaultBadgeLabel
|
||||
|
||||
if (isPlain) {
|
||||
return (
|
||||
|
|
@ -56,7 +56,7 @@ const UpgradeBtn: FC<Props> = ({
|
|||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{labelKey ? label : t('billing.upgradeBtn.plain')}
|
||||
{labelKey ? label : t('billing.upgradeBtn.plain' as any) as string}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const CustomWebAppBrand = () => {
|
|||
setFileId(res.id)
|
||||
},
|
||||
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 })
|
||||
setUploadProgress(-1)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export const useUpload = () => {
|
|||
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)
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
|
|
@ -187,7 +187,7 @@ export const useUpload = () => {
|
|||
})
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t as any)
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
|
|||
<div className="space-y-2">
|
||||
<RadioCard
|
||||
icon={icon}
|
||||
title={t(`dataset.retrieval.${type}.title`)}
|
||||
description={t(`dataset.retrieval.${type}.description`)}
|
||||
title={t(`dataset.retrieval.${type}.title` as any) as string}
|
||||
description={t(`dataset.retrieval.${type}.description` as any) as string}
|
||||
noRadio
|
||||
chosenConfigWrapClassName="!pb-3"
|
||||
chosenConfig={(
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const Content = ({
|
|||
{name}
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[chunkStructure]}`)}
|
||||
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[chunkStructure]}` as any) as string}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ const RuleDetail: FC<{
|
|||
<FieldInfo
|
||||
label={t('datasetSettings.form.retrievalSetting.title')}
|
||||
// displayedValue={t(`datasetSettings.form.retrievalSetting.${retrievalMethod}`) as string}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title` as any) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ const FileUploader = ({
|
|||
return Promise.resolve({ ...completeFile })
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t as any)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
onFileUpdate(fileItem, -2, fileListRef.current)
|
||||
return Promise.resolve({ ...fileItem })
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const TopBar: FC<TopBarProps> = (props) => {
|
|||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Stepper
|
||||
steps={Array.from({ length: 3 }, (_, i) => ({
|
||||
name: t(STEP_T_MAP[i + 1]),
|
||||
name: t(STEP_T_MAP[i + 1] as any) as string,
|
||||
}))}
|
||||
{...rest}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ const LocalFile = ({
|
|||
return Promise.resolve({ ...completeFile })
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t as any)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
updateFile(fileItem, -2, fileListRef.current)
|
||||
return Promise.resolve({ ...fileItem })
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ const RuleDetail = ({
|
|||
/>
|
||||
<FieldInfo
|
||||
label={t('datasetSettings.form.retrievalSetting.title')}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title` as any) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const CSVUploader: FC<Props> = ({
|
|||
return Promise.resolve({ ...completeFile })
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
|
||||
const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t as any)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
const errorFile = {
|
||||
...fileItem,
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ const RuleDetail: FC<IRuleDetailProps> = React.memo(({
|
|||
/>
|
||||
<FieldInfo
|
||||
label={t('datasetSettings.form.retrievalSetting.title')}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title`) as string}
|
||||
displayedValue={t(`dataset.retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod}.title` as any) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ const QueryInput = ({
|
|||
className="flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg border-[0.5px] border-components-button-secondary-bg bg-components-button-secondary-bg px-1.5 shadow-xs backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
{icon}
|
||||
<div className="text-xs font-medium uppercase text-text-secondary">{t(`dataset.retrieval.${retrievalMethod}.title`)}</div>
|
||||
<div className="text-xs font-medium uppercase text-text-secondary">{t(`dataset.retrieval.${retrievalMethod}.title` as any) as string}</div>
|
||||
<RiEqualizer2Line className="size-4 text-components-menu-item-text"></RiEqualizer2Line>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -217,17 +217,17 @@ const DatasetCard = ({
|
|||
{dataset.doc_form && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}
|
||||
title={t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string}
|
||||
>
|
||||
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}
|
||||
{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}` as any) as string}
|
||||
</span>
|
||||
)}
|
||||
{dataset.indexing_technique && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}
|
||||
title={formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any}
|
||||
>
|
||||
{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}
|
||||
{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any}
|
||||
</span>
|
||||
)}
|
||||
{dataset.is_multimodal && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,279 @@
|
|||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppList from './index'
|
||||
|
||||
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
|
||||
let mockTabValue = allCategoriesEn
|
||||
const mockSetTab = vi.fn()
|
||||
let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] }
|
||||
let mockIsLoading = false
|
||||
let mockIsError = false
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-tab-searchparams', () => ({
|
||||
useTabSearchParams: () => [mockTabValue, mockSetTab],
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => {
|
||||
const fnRef = React.useRef(fn)
|
||||
fnRef.current = fn
|
||||
return {
|
||||
run: () => setTimeout(() => fnRef.current(), 0),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useExploreAppList: () => ({
|
||||
data: mockExploreData,
|
||||
isLoading: mockIsLoading,
|
||||
isError: mockIsError,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchAppDetail: vi.fn(),
|
||||
fetchAppList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
useImportDSL: () => ({
|
||||
handleImportDSL: mockHandleImportDSL,
|
||||
handleImportDSLConfirm: mockHandleImportDSLConfirm,
|
||||
versions: ['v1'],
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
__esModule: true,
|
||||
default: (props: CreateAppModalProps) => {
|
||||
if (!props.show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-app-modal">
|
||||
<button
|
||||
data-testid="confirm-create"
|
||||
onClick={() => props.onConfirm({
|
||||
name: 'New App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
description: 'desc',
|
||||
})}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
<button data-testid="hide-create" onClick={props.onHide}>hide</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
|
||||
<div data-testid="dsl-confirm-modal">
|
||||
<button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button>
|
||||
<button data-testid="dsl-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '😀',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'Alpha',
|
||||
description: overrides.app?.description ?? 'Alpha description',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
app_id: overrides.app_id ?? 'app-1',
|
||||
description: overrides.description ?? 'Alpha description',
|
||||
copyright: overrides.copyright ?? '',
|
||||
privacy_policy: overrides.privacy_policy ?? null,
|
||||
custom_disclaimer: overrides.custom_disclaimer ?? null,
|
||||
category: overrides.category ?? 'Writing',
|
||||
position: overrides.position ?? 1,
|
||||
is_listed: overrides.is_listed ?? true,
|
||||
install_count: overrides.install_count ?? 0,
|
||||
installed: overrides.installed ?? false,
|
||||
editable: overrides.editable ?? false,
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => {
|
||||
return render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AppList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTabValue = allCategoriesEn
|
||||
mockExploreData = { categories: [], allList: [] }
|
||||
mockIsLoading = false
|
||||
mockIsError = false
|
||||
})
|
||||
|
||||
// Rendering: show loading when categories are not ready.
|
||||
describe('Rendering', () => {
|
||||
it('should render loading when the query is loading', () => {
|
||||
// Arrange
|
||||
mockExploreData = undefined
|
||||
mockIsLoading = true
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when data is available', () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: category selection filters the list.
|
||||
describe('Props', () => {
|
||||
it('should filter apps by selected category', () => {
|
||||
// Arrange
|
||||
mockTabValue = 'Writing'
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: search and create flow.
|
||||
describe('User Interactions', () => {
|
||||
it('should filter apps by search keywords', async () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle create flow and confirm DSL when pending', async () => {
|
||||
// Arrange
|
||||
const onSuccess = vi.fn()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
// Act
|
||||
renderWithContext(true, onSuccess)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id')
|
||||
})
|
||||
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
|
||||
expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('dsl-confirm'))
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: handle clearing search keywords.
|
||||
describe('Edge Cases', () => {
|
||||
it('should reset search results when clear icon is clicked', async () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('input-clear'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -6,7 +6,6 @@ import { useDebounceFn } from 'ahooks'
|
|||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
|
@ -20,7 +19,8 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|||
import {
|
||||
DSLImportMode,
|
||||
} from '@/models/app'
|
||||
import { fetchAppDetail, fetchAppList } from '@/service/explore'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { useExploreAppList } from '@/service/use-explore'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './style.module.css'
|
||||
|
||||
|
|
@ -28,11 +28,6 @@ type AppsProps = {
|
|||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export enum PageType {
|
||||
EXPLORE = 'explore',
|
||||
CREATE = 'create',
|
||||
}
|
||||
|
||||
const Apps = ({
|
||||
onSuccess,
|
||||
}: AppsProps) => {
|
||||
|
|
@ -58,23 +53,16 @@ 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,
|
||||
isError,
|
||||
} = useExploreAppList()
|
||||
|
||||
const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory)
|
||||
const filteredList = useMemo(() => {
|
||||
if (!data)
|
||||
return []
|
||||
return data.allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory)
|
||||
}, [data, currCategory, allCategoriesEn])
|
||||
|
||||
const searchFilteredList = useMemo(() => {
|
||||
if (!searchKeywords || !filteredList || filteredList.length === 0)
|
||||
|
|
@ -132,7 +120,7 @@ const Apps = ({
|
|||
})
|
||||
}, [handleImportDSLConfirm, onSuccess])
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Loading type="area" />
|
||||
|
|
@ -140,6 +128,11 @@ const Apps = ({
|
|||
)
|
||||
}
|
||||
|
||||
if (isError || !data)
|
||||
return null
|
||||
|
||||
const { categories } = data
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full flex-col border-l-[0.5px] border-divider-regular',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import type { AppCategory } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Category from './category'
|
||||
|
||||
describe('Category', () => {
|
||||
const allCategoriesEn = 'Recommended'
|
||||
|
||||
const renderComponent = (overrides: Partial<React.ComponentProps<typeof Category>> = {}) => {
|
||||
const props: React.ComponentProps<typeof Category> = {
|
||||
list: ['Writing', 'Recommended'] as AppCategory[],
|
||||
value: allCategoriesEn,
|
||||
onChange: vi.fn(),
|
||||
allCategoriesEn,
|
||||
...overrides,
|
||||
}
|
||||
return {
|
||||
props,
|
||||
...render(<Category {...props} />),
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering: basic categories and all-categories button.
|
||||
describe('Rendering', () => {
|
||||
it('should render all categories item and translated categories', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.category.Writing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render allCategoriesEn again inside the category list', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const recommendedItems = screen.getAllByText('explore.apps.allCategories')
|
||||
expect(recommendedItems).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Props: clicking items triggers onChange.
|
||||
describe('Props', () => {
|
||||
it('should call onChange with category value when category item is clicked', () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('explore.category.Writing'))
|
||||
|
||||
// Assert
|
||||
expect(props.onChange).toHaveBeenCalledWith('Writing')
|
||||
})
|
||||
|
||||
it('should call onChange with allCategoriesEn when all categories is clicked', () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent({ value: 'Writing' })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('explore.apps.allCategories'))
|
||||
|
||||
// Assert
|
||||
expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: handle values not in the list.
|
||||
describe('Edge Cases', () => {
|
||||
it('should treat unknown value as all categories selection', () => {
|
||||
// Arrange
|
||||
renderComponent({ value: 'Unknown' })
|
||||
|
||||
// Assert
|
||||
const allCategoriesItem = screen.getByText('explore.apps.allCategories')
|
||||
expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -50,7 +50,7 @@ const Category: FC<ICategoryProps> = ({
|
|||
className={itemClassName(name === value)}
|
||||
onClick={() => onChange(name)}
|
||||
>
|
||||
{(categoryI18n as any)[name] ? t(`explore.category.${name}`) : name}
|
||||
{(categoryI18n as any)[name] ? t(`explore.category.${name}` as any) as string : name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import type { Mock } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import Explore from './index'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockInstalledAppsData = { installed_apps: [] as const }
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
push: mockPush,
|
||||
}),
|
||||
useSelectedLayoutSegments: () => ['apps'],
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
__esModule: true,
|
||||
default: () => MediaType.pc,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isFetching: false,
|
||||
data: mockInstalledAppsData,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const ContextReader = () => {
|
||||
const { hasEditPermission } = useContext(ExploreContext)
|
||||
return <div>{hasEditPermission ? 'edit-yes' : 'edit-no'}</div>
|
||||
}
|
||||
|
||||
describe('Explore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: provides ExploreContext and children.
|
||||
describe('Rendering', () => {
|
||||
it('should render children and provide edit permission from members role', async () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({
|
||||
data: {
|
||||
accounts: [{ id: 'user-1', role: 'admin' }],
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<ContextReader />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit-yes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Effects: set document title and redirect dataset operators.
|
||||
describe('Effects', () => {
|
||||
it('should set document title on render', () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
|
||||
})
|
||||
|
||||
it('should redirect dataset operators to /datasets', async () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ItemOperation from './index'
|
||||
|
||||
describe('ItemOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderComponent = (overrides: Partial<React.ComponentProps<typeof ItemOperation>> = {}) => {
|
||||
const props: React.ComponentProps<typeof ItemOperation> = {
|
||||
isPinned: false,
|
||||
isShowDelete: true,
|
||||
togglePin: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
return {
|
||||
props,
|
||||
...render(<ItemOperation {...props} />),
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering: menu items show after opening.
|
||||
describe('Rendering', () => {
|
||||
it('should render pin and delete actions when menu is open', async () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: render optional rename action and pinned label text.
|
||||
describe('Props', () => {
|
||||
it('should render rename action when isShowRenameConversation is true', async () => {
|
||||
// Arrange
|
||||
renderComponent({ isShowRenameConversation: true })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unpin label when isPinned is true', async () => {
|
||||
// Arrange
|
||||
renderComponent({ isPinned: true })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking action items triggers callbacks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call togglePin when clicking pin action', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
// Assert
|
||||
expect(props.togglePin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDelete when clicking delete action', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Assert
|
||||
expect(props.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: menu closes after mouse leave when no hovering state remains.
|
||||
describe('Edge Cases', () => {
|
||||
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
const pinText = await screen.findByText('explore.sidebar.action.pin')
|
||||
const menu = pinText.closest('div')?.parentElement as HTMLElement
|
||||
|
||||
// Act
|
||||
fireEvent.mouseEnter(menu)
|
||||
fireEvent.mouseLeave(menu)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -53,7 +53,11 @@ const ItemOperation: FC<IItemOperationProps> = ({
|
|||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} !bg-components-actionbar-bg !shadow-none`)}></div>
|
||||
<div
|
||||
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} !bg-components-actionbar-bg !shadow-none`)}
|
||||
data-testid="item-operation-trigger"
|
||||
>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-50"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AppNavItem from './index'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
isMobile: false,
|
||||
name: 'My App',
|
||||
id: 'app-123',
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
isSelected: false,
|
||||
isPinned: false,
|
||||
togglePin: vi.fn(),
|
||||
uninstallable: false,
|
||||
onDelete: vi.fn(),
|
||||
}
|
||||
|
||||
describe('AppNavItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: display app name for desktop and hide for mobile.
|
||||
describe('Rendering', () => {
|
||||
it('should render name and item operation on desktop', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide name on mobile', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} isMobile />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('My App')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: navigation and delete flow.
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to installed app when item is clicked', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('My App'))
|
||||
|
||||
// Assert
|
||||
expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123')
|
||||
})
|
||||
|
||||
it('should call onDelete with app id when delete action is clicked', async () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Assert
|
||||
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: hide delete when uninstallable or selected.
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render delete action when app is uninstallable', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} uninstallable />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SideBar from './index'
|
||||
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockIsFetching = false
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
__esModule: true,
|
||||
default: () => MediaType.pc,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isFetching: mockIsFetching,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
|
||||
id: overrides.id ?? 'app-123',
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
is_pinned: overrides.is_pinned ?? false,
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '🤖',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'My App',
|
||||
description: overrides.app?.description ?? 'desc',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithContext = (installedApps: InstalledApp[] = []) => {
|
||||
return render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps,
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<SideBar controlUpdateInstalledApps={0} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SideBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsFetching = false
|
||||
mockInstalledApps = []
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
// Rendering: show discovery and workspace section.
|
||||
describe('Rendering', () => {
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
|
||||
// Act
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument()
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Effects: refresh and sync installed apps state.
|
||||
describe('Effects', () => {
|
||||
it('should refetch installed apps on mount', () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
|
||||
// Act
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Assert
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: delete and pin flows.
|
||||
describe('User Interactions', () => {
|
||||
it('should uninstall app and show toast when delete is confirmed', async () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
fireEvent.click(await screen.findByText('common.operation.confirm'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-123')
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.api.remove',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should update pin status and show toast when pin is clicked', async () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.api.success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -36,13 +36,13 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult
|
|||
const q = query.toLowerCase()
|
||||
const list = THEME_ITEMS.filter(item =>
|
||||
!q
|
||||
|| i18n.t(item.titleKey, { lng: locale }).toLowerCase().includes(q)
|
||||
|| i18n.t(item.titleKey as any, { lng: locale }).toLowerCase().includes(q)
|
||||
|| item.id.includes(q),
|
||||
)
|
||||
return list.map(item => ({
|
||||
id: item.id,
|
||||
title: i18n.t(item.titleKey, { lng: locale }),
|
||||
description: i18n.t(item.descKey, { lng: locale }),
|
||||
title: i18n.t(item.titleKey as any, { lng: locale }),
|
||||
description: i18n.t(item.descKey as any, { lng: locale }),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
|||
'/community': 'app.gotoAnything.actions.communityDesc',
|
||||
'/zen': 'app.gotoAnything.actions.zenDesc',
|
||||
}
|
||||
return t(slashKeyMap[item.key] || item.description)
|
||||
return t((slashKeyMap[item.key] || item.description) as any)
|
||||
})()
|
||||
)
|
||||
: (
|
||||
|
|
@ -128,7 +128,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
|||
'@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc',
|
||||
'@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc',
|
||||
}
|
||||
return t(keyMap[item.key])
|
||||
return t(keyMap[item.key] as any) as string
|
||||
})()
|
||||
)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ const GotoAnything: FC<Props> = ({
|
|||
knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
|
||||
node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
|
||||
}
|
||||
return t(keyMap[commandType] || 'app.gotoAnything.noResults')
|
||||
return t((keyMap[commandType] || 'app.gotoAnything.noResults') as any)
|
||||
})()
|
||||
: t('app.gotoAnything.noResults')}
|
||||
</div>
|
||||
|
|
@ -410,7 +410,7 @@ const GotoAnything: FC<Props> = ({
|
|||
'workflow-node': 'app.gotoAnything.groups.workflowNodes',
|
||||
'command': 'app.gotoAnything.groups.commands',
|
||||
}
|
||||
return t(typeMap[type] || `${type}s`)
|
||||
return t((typeMap[type] || `${type}s`) as any)
|
||||
})()}
|
||||
className="p-2 capitalize text-text-secondary"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
|
@ -32,7 +33,7 @@ export default function LanguagePage() {
|
|||
await updateUserProfile({ url, body: { [bodyKey]: item.value } })
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
|
||||
setLocaleOnClient(item.value.toString())
|
||||
setLocaleOnClient(item.value.toString() as Locale)
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
|
|||
className="block"
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}`) })}</div>
|
||||
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}` as any) })}</div>
|
||||
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
|
|
|
|||
|
|
@ -106,8 +106,8 @@ const Operation = ({
|
|||
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
}
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}`)}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip`)}</div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
|
|
|||
|
|
@ -6,20 +6,22 @@ type ModelDisplayProps = {
|
|||
}
|
||||
|
||||
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
|
||||
return currentModel ? (
|
||||
<ModelName
|
||||
className="flex grow items-center gap-1 px-1 py-[3px]"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
) : (
|
||||
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
|
||||
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
|
||||
{modelId}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return currentModel
|
||||
? (
|
||||
<ModelName
|
||||
className="flex grow items-center gap-1 px-1 py-[3px]"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
|
||||
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
|
||||
{modelId}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelDisplay
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const PresetsParameter: FC<PresetsParameterProps> = ({
|
|||
text: (
|
||||
<div className="flex h-full items-center">
|
||||
{getToneIcon(tone.id)}
|
||||
{t(`common.model.tone.${tone.name}`) as string}
|
||||
{t(`common.model.tone.${tone.name}` as any) as string}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
|||
),
|
||||
}}
|
||||
values={{
|
||||
deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`),
|
||||
deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}` as any) as string,
|
||||
alternativePluginId,
|
||||
}}
|
||||
/>
|
||||
|
|
@ -91,7 +91,7 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
|||
{
|
||||
hasValidDeprecatedReason && !alternativePluginId && (
|
||||
<span>
|
||||
{t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })}
|
||||
{t(`${i18nPrefix}.onlyReason` as any, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}` as any) as string }) as string}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
'use client'
|
||||
import type { Plugin } from '../types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import {
|
||||
renderI18nObject,
|
||||
} from '@/i18n-config'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { Theme } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
|
@ -30,7 +33,7 @@ export type Props = {
|
|||
footer?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
locale?: string
|
||||
locale?: Locale
|
||||
limitedInstall?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const useTags = (translateFromOut?: TFunction) => {
|
|||
return tagKeys.map((tag) => {
|
||||
return {
|
||||
name: tag,
|
||||
label: t(`pluginTags.tags.${tag}`),
|
||||
label: t(`pluginTags.tags.${tag}` as any) as string,
|
||||
}
|
||||
})
|
||||
}, [t])
|
||||
|
|
@ -66,14 +66,14 @@ export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean)
|
|||
}
|
||||
return {
|
||||
name: category,
|
||||
label: isSingle ? t(`plugin.categorySingle.${category}`) : t(`plugin.category.${category}s`),
|
||||
label: isSingle ? t(`plugin.categorySingle.${category}` as any) as string : t(`plugin.category.${category}s` as any) as string,
|
||||
}
|
||||
})
|
||||
}, [t, isSingle])
|
||||
|
||||
const categoriesMap = useMemo(() => {
|
||||
return categories.reduce((acc, category) => {
|
||||
acc[category.name] = category
|
||||
acc[category.name] = category as any
|
||||
return acc
|
||||
}, {} as Record<string, Category>)
|
||||
}, [categories])
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue