mirror of
https://github.com/langgenius/dify.git
synced 2026-01-02 12:37:24 +00:00
Compare commits
14 Commits
refactor/q
...
fix/surfac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7d997a7f | ||
|
|
fa69cce1e7 | ||
|
|
f28a08a696 | ||
|
|
8129b04143 | ||
|
|
1b8e80a722 | ||
|
|
8295134c18 | ||
|
|
bf431fb9d3 | ||
|
|
8c8c79b6ba | ||
|
|
630b9d0145 | ||
|
|
b6b7ff0aeb | ||
|
|
16aa9254ad | ||
|
|
1ddaece735 | ||
|
|
9e990c5ccd | ||
|
|
b61fd8fcff |
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
@@ -73,6 +74,48 @@ class AppListQuery(BaseModel):
|
||||
raise ValueError("Invalid UUID format in tag_ids.") from exc
|
||||
|
||||
|
||||
# XSS prevention: patterns that could lead to XSS attacks
|
||||
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
|
||||
_XSS_PATTERNS = [
|
||||
r"<script[^>]*>.*?</script>", # Script tags
|
||||
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
|
||||
r"javascript:", # JavaScript protocol
|
||||
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
|
||||
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
|
||||
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
|
||||
r"<embed[^>]*>", # Embed tags (self-closing)
|
||||
r"<link[^>]*>", # Link tags with javascript
|
||||
]
|
||||
|
||||
|
||||
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
|
||||
"""
|
||||
Validate that a string value doesn't contain potential XSS payloads.
|
||||
|
||||
Args:
|
||||
value: The string value to validate
|
||||
field_name: Name of the field for error messages
|
||||
|
||||
Returns:
|
||||
The original value if safe
|
||||
|
||||
Raises:
|
||||
ValueError: If the value contains XSS patterns
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
value_lower = value.lower()
|
||||
for pattern in _XSS_PATTERNS:
|
||||
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
|
||||
raise ValueError(
|
||||
f"{field_name} contains invalid characters or patterns. "
|
||||
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class CreateAppPayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, description="App name")
|
||||
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
|
||||
@@ -81,6 +124,11 @@ class CreateAppPayload(BaseModel):
|
||||
icon: str | None = Field(default=None, description="Icon")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def validate_xss_safe(cls, value: str | None, info) -> str | None:
|
||||
return _validate_xss_safe(value, info.field_name)
|
||||
|
||||
|
||||
class UpdateAppPayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, description="App name")
|
||||
@@ -91,6 +139,11 @@ class UpdateAppPayload(BaseModel):
|
||||
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
|
||||
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def validate_xss_safe(cls, value: str | None, info) -> str | None:
|
||||
return _validate_xss_safe(value, info.field_name)
|
||||
|
||||
|
||||
class CopyAppPayload(BaseModel):
|
||||
name: str | None = Field(default=None, description="Name for the copied app")
|
||||
@@ -99,6 +152,11 @@ class CopyAppPayload(BaseModel):
|
||||
icon: str | None = Field(default=None, description="Icon")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def validate_xss_safe(cls, value: str | None, info) -> str | None:
|
||||
return _validate_xss_safe(value, info.field_name)
|
||||
|
||||
|
||||
class AppExportQuery(BaseModel):
|
||||
include_secret: bool = Field(default=False, description="Include secrets in export")
|
||||
|
||||
@@ -4,12 +4,11 @@ from typing import Any
|
||||
|
||||
from flask import make_response, redirect, request
|
||||
from flask_restx import Resource, reqparse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
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
|
||||
@@ -44,6 +43,12 @@ class TriggerSubscriptionUpdateRequest(BaseModel):
|
||||
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")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_at_least_one_field(self):
|
||||
if all(v is None for v in (self.name, self.credentials, self.parameters, self.properties)):
|
||||
raise ValueError("At least one of name, credentials, parameters, or properties must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class TriggerSubscriptionVerifyRequest(BaseModel):
|
||||
"""Request payload for verifying subscription credentials."""
|
||||
@@ -333,7 +338,7 @@ class TriggerSubscriptionUpdateApi(Resource):
|
||||
user = current_user
|
||||
assert user.current_tenant_id is not None
|
||||
|
||||
args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
|
||||
request = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
|
||||
|
||||
subscription = TriggerProviderService.get_subscription_by_id(
|
||||
tenant_id=user.current_tenant_id,
|
||||
@@ -345,50 +350,32 @@ class TriggerSubscriptionUpdateApi(Resource):
|
||||
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
|
||||
):
|
||||
# For rename only, just update the name
|
||||
rename = request.name is not None and not any((request.credentials, request.parameters, request.properties))
|
||||
# When credential type is UNAUTHORIZED, it indicates the subscription was manually created
|
||||
# For Manually created subscription, they dont have credentials, parameters
|
||||
# They only have name and properties(which is input by user)
|
||||
manually_created = subscription.credential_type == CredentialType.UNAUTHORIZED
|
||||
if rename or manually_created:
|
||||
TriggerProviderService.update_trigger_subscription(
|
||||
tenant_id=user.current_tenant_id,
|
||||
subscription_id=subscription_id,
|
||||
name=args.name,
|
||||
name=request.name,
|
||||
properties=request.properties,
|
||||
)
|
||||
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")
|
||||
# For the rest cases(API_KEY, OAUTH2)
|
||||
# we need to call third party provider(e.g. GitHub) to rebuild the subscription
|
||||
TriggerProviderService.rebuild_trigger_subscription(
|
||||
tenant_id=user.current_tenant_id,
|
||||
name=request.name,
|
||||
provider_id=provider_id,
|
||||
subscription_id=subscription_id,
|
||||
credentials=request.credentials or subscription.credentials,
|
||||
parameters=request.parameters or subscription.parameters,
|
||||
)
|
||||
return 200
|
||||
except ValueError as e:
|
||||
raise BadRequest(str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -853,7 +853,7 @@ class TriggerProviderService:
|
||||
"""
|
||||
Create a subscription builder for rebuilding an existing subscription.
|
||||
|
||||
This method creates a builder pre-filled with data from the rebuild request,
|
||||
This method rebuild the subscription by call DELETE and CREATE API of the third party provider(e.g. GitHub)
|
||||
keeping the same subscription_id and endpoint_id so the webhook URL remains unchanged.
|
||||
|
||||
:param tenant_id: Tenant ID
|
||||
@@ -868,111 +868,50 @@ class TriggerProviderService:
|
||||
if not provider_controller:
|
||||
raise ValueError(f"Provider {provider_id} not found")
|
||||
|
||||
# Use distributed lock to prevent race conditions on the same subscription
|
||||
lock_key = f"trigger_subscription_rebuild_lock:{tenant_id}_{subscription_id}"
|
||||
with redis_client.lock(lock_key, timeout=20):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
# Get subscription within the transaction
|
||||
subscription: TriggerSubscription | None = (
|
||||
session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first()
|
||||
)
|
||||
if not subscription:
|
||||
raise ValueError(f"Subscription {subscription_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")
|
||||
credential_type = CredentialType.of(subscription.credential_type)
|
||||
if credential_type not in {CredentialType.OAUTH2, CredentialType.API_KEY}:
|
||||
raise ValueError(f"Credential type {credential_type} not supported for auto creation")
|
||||
|
||||
# Decrypt existing credentials for merging
|
||||
credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription(
|
||||
tenant_id=tenant_id,
|
||||
controller=provider_controller,
|
||||
subscription=subscription,
|
||||
)
|
||||
decrypted_credentials = dict(credential_encrypter.decrypt(subscription.credentials))
|
||||
# Delete the previous subscription
|
||||
user_id = subscription.user_id
|
||||
unsubscribe_result = 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,
|
||||
)
|
||||
if not unsubscribe_result.success:
|
||||
raise ValueError(f"Failed to delete previous subscription: {unsubscribe_result.message}")
|
||||
|
||||
# Merge credentials: if caller passed HIDDEN_VALUE, retain existing decrypted value
|
||||
merged_credentials: dict[str, Any] = {
|
||||
key: value if value != HIDDEN_VALUE else decrypted_credentials.get(key, UNKNOWN_VALUE)
|
||||
for key, value in credentials.items()
|
||||
}
|
||||
|
||||
user_id = subscription.user_id
|
||||
|
||||
# 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
|
||||
|
||||
# Unsubscribe the previous subscription (external call, but we'll handle errors)
|
||||
try:
|
||||
TriggerManager.unsubscribe_trigger(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
provider_id=provider_id,
|
||||
subscription=subscription.to_entity(),
|
||||
credentials=decrypted_credentials,
|
||||
credential_type=credential_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error unsubscribing trigger during rebuild", exc_info=e)
|
||||
# Continue anyway - the subscription might already be deleted externally
|
||||
|
||||
# Create a new subscription with the same subscription_id and endpoint_id (external call)
|
||||
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=merged_credentials,
|
||||
credential_type=credential_type,
|
||||
)
|
||||
|
||||
# Update the subscription in the same transaction
|
||||
# Inline update logic to reuse the same session
|
||||
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 and existing.id != subscription.id:
|
||||
raise ValueError(f"Subscription name '{name}' already exists for this provider")
|
||||
subscription.name = name
|
||||
|
||||
# Update parameters
|
||||
subscription.parameters = dict(parameters)
|
||||
|
||||
# Update credentials with merged (and encrypted) values
|
||||
subscription.credentials = dict(credential_encrypter.encrypt(merged_credentials))
|
||||
|
||||
# Update properties
|
||||
if new_subscription.properties:
|
||||
properties_encrypter, _ = create_provider_encrypter(
|
||||
tenant_id=tenant_id,
|
||||
config=provider_controller.get_properties_schema(),
|
||||
cache=NoOpProviderCredentialCache(),
|
||||
)
|
||||
subscription.properties = dict(properties_encrypter.encrypt(dict(new_subscription.properties)))
|
||||
|
||||
# Update expiration timestamp
|
||||
if new_subscription.expires_at is not None:
|
||||
subscription.expires_at = new_subscription.expires_at
|
||||
|
||||
# Commit the transaction
|
||||
session.commit()
|
||||
|
||||
# Clear subscription cache
|
||||
delete_cache_for_subscription(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=subscription.provider_id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Rollback on any error
|
||||
session.rollback()
|
||||
logger.exception("Failed to rebuild trigger subscription", exc_info=e)
|
||||
raise
|
||||
# Create a new subscription with the same subscription_id and endpoint_id
|
||||
new_credentials: dict[str, Any] = {
|
||||
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
|
||||
for key, value in credentials.items()
|
||||
}
|
||||
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=new_credentials,
|
||||
credential_type=credential_type,
|
||||
)
|
||||
TriggerProviderService.update_trigger_subscription(
|
||||
tenant_id=tenant_id,
|
||||
subscription_id=subscription.id,
|
||||
name=name,
|
||||
parameters=parameters,
|
||||
credentials=new_credentials,
|
||||
properties=new_subscription.properties,
|
||||
expires_at=new_subscription.expires_at,
|
||||
)
|
||||
|
||||
@@ -474,64 +474,6 @@ class TestTriggerProviderService:
|
||||
assert subscription.name == original_name
|
||||
assert subscription.parameters == original_parameters
|
||||
|
||||
def test_rebuild_trigger_subscription_unsubscribe_error_continues(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test that unsubscribe errors are handled gracefully and operation continues.
|
||||
|
||||
This test verifies:
|
||||
- Unsubscribe errors are caught and logged but don't stop the rebuild
|
||||
- Rebuild continues even if unsubscribe fails
|
||||
"""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
provider_id = TriggerProviderID("test_org/test_plugin/test_provider")
|
||||
credential_type = CredentialType.API_KEY
|
||||
|
||||
original_credentials = {"api_key": "original-key"}
|
||||
subscription = self._create_test_subscription(
|
||||
db_session_with_containers,
|
||||
tenant.id,
|
||||
account.id,
|
||||
provider_id,
|
||||
credential_type,
|
||||
original_credentials,
|
||||
mock_external_service_dependencies,
|
||||
)
|
||||
|
||||
# Make unsubscribe_trigger raise an error (should be caught and continue)
|
||||
mock_external_service_dependencies["trigger_manager"].unsubscribe_trigger.side_effect = ValueError(
|
||||
"Unsubscribe failed"
|
||||
)
|
||||
|
||||
new_subscription_entity = TriggerSubscriptionEntity(
|
||||
endpoint=subscription.endpoint_id,
|
||||
parameters={},
|
||||
properties={},
|
||||
expires_at=-1,
|
||||
)
|
||||
mock_external_service_dependencies["trigger_manager"].subscribe_trigger.return_value = new_subscription_entity
|
||||
|
||||
# Execute rebuild - should succeed despite unsubscribe error
|
||||
TriggerProviderService.rebuild_trigger_subscription(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider_id,
|
||||
subscription_id=subscription.id,
|
||||
credentials={"api_key": "new-key"},
|
||||
parameters={},
|
||||
)
|
||||
|
||||
# Verify subscribe was still called (operation continued)
|
||||
mock_external_service_dependencies["trigger_manager"].subscribe_trigger.assert_called_once()
|
||||
|
||||
# Verify subscription was updated
|
||||
db.session.refresh(subscription)
|
||||
assert subscription.parameters == {}
|
||||
|
||||
def test_rebuild_trigger_subscription_subscription_not_found(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
@@ -558,70 +500,6 @@ class TestTriggerProviderService:
|
||||
parameters={},
|
||||
)
|
||||
|
||||
def test_rebuild_trigger_subscription_provider_not_found(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test error when provider is not found.
|
||||
|
||||
This test verifies:
|
||||
- Proper error is raised when provider doesn't exist
|
||||
"""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
provider_id = TriggerProviderID("non_existent_org/non_existent_plugin/non_existent_provider")
|
||||
|
||||
# Make get_trigger_provider return None
|
||||
mock_external_service_dependencies["trigger_manager"].get_trigger_provider.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Provider.*not found"):
|
||||
TriggerProviderService.rebuild_trigger_subscription(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider_id,
|
||||
subscription_id=fake.uuid4(),
|
||||
credentials={},
|
||||
parameters={},
|
||||
)
|
||||
|
||||
def test_rebuild_trigger_subscription_unsupported_credential_type(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test error when credential type is not supported for rebuild.
|
||||
|
||||
This test verifies:
|
||||
- Proper error is raised for unsupported credential types (not OAUTH2 or API_KEY)
|
||||
"""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
provider_id = TriggerProviderID("test_org/test_plugin/test_provider")
|
||||
credential_type = CredentialType.UNAUTHORIZED # Not supported
|
||||
|
||||
subscription = self._create_test_subscription(
|
||||
db_session_with_containers,
|
||||
tenant.id,
|
||||
account.id,
|
||||
provider_id,
|
||||
credential_type,
|
||||
{},
|
||||
mock_external_service_dependencies,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Credential type not supported for rebuild"):
|
||||
TriggerProviderService.rebuild_trigger_subscription(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider_id,
|
||||
subscription_id=subscription.id,
|
||||
credentials={},
|
||||
parameters={},
|
||||
)
|
||||
|
||||
def test_rebuild_trigger_subscription_name_uniqueness_check(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Unit tests for XSS prevention in App payloads.
|
||||
|
||||
This test module validates that HTML tags, JavaScript, and other potentially
|
||||
dangerous content are rejected in App names and descriptions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
|
||||
|
||||
|
||||
class TestXSSPreventionUnit:
|
||||
"""Unit tests for XSS prevention in App payloads."""
|
||||
|
||||
def test_create_app_valid_names(self):
|
||||
"""Test CreateAppPayload with valid app names."""
|
||||
# Normal app names should be valid
|
||||
valid_names = [
|
||||
"My App",
|
||||
"Test App 123",
|
||||
"App with - dash",
|
||||
"App with _ underscore",
|
||||
"App with + plus",
|
||||
"App with () parentheses",
|
||||
"App with [] brackets",
|
||||
"App with {} braces",
|
||||
"App with ! exclamation",
|
||||
"App with @ at",
|
||||
"App with # hash",
|
||||
"App with $ dollar",
|
||||
"App with % percent",
|
||||
"App with ^ caret",
|
||||
"App with & ampersand",
|
||||
"App with * asterisk",
|
||||
"Unicode: 测试应用",
|
||||
"Emoji: 🤖",
|
||||
"Mixed: Test 测试 123",
|
||||
]
|
||||
|
||||
for name in valid_names:
|
||||
payload = CreateAppPayload(
|
||||
name=name,
|
||||
mode="chat",
|
||||
)
|
||||
assert payload.name == name
|
||||
|
||||
def test_create_app_xss_script_tags(self):
|
||||
"""Test CreateAppPayload rejects script tags."""
|
||||
xss_payloads = [
|
||||
"<script>alert(document.cookie)</script>",
|
||||
"<Script>alert(1)</Script>",
|
||||
"<SCRIPT>alert('XSS')</SCRIPT>",
|
||||
"<script>alert(String.fromCharCode(88,83,83))</script>",
|
||||
"<script src='evil.js'></script>",
|
||||
"<script>document.location='http://evil.com'</script>",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_iframe_tags(self):
|
||||
"""Test CreateAppPayload rejects iframe tags."""
|
||||
xss_payloads = [
|
||||
"<iframe src='evil.com'></iframe>",
|
||||
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
|
||||
"<IFRAME src='javascript:alert(1)'></iframe>",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_javascript_protocol(self):
|
||||
"""Test CreateAppPayload rejects javascript: protocol."""
|
||||
xss_payloads = [
|
||||
"javascript:alert(1)",
|
||||
"JAVASCRIPT:alert(1)",
|
||||
"JavaScript:alert(document.cookie)",
|
||||
"javascript:void(0)",
|
||||
"javascript://comment%0Aalert(1)",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_svg_onload(self):
|
||||
"""Test CreateAppPayload rejects SVG with onload."""
|
||||
xss_payloads = [
|
||||
"<svg onload=alert(1)>",
|
||||
"<SVG ONLOAD=alert(1)>",
|
||||
"<svg/x/onload=alert(1)>",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_event_handlers(self):
|
||||
"""Test CreateAppPayload rejects HTML event handlers."""
|
||||
xss_payloads = [
|
||||
"<div onclick=alert(1)>",
|
||||
"<img onerror=alert(1)>",
|
||||
"<body onload=alert(1)>",
|
||||
"<input onfocus=alert(1)>",
|
||||
"<a onmouseover=alert(1)>",
|
||||
"<DIV ONCLICK=alert(1)>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_object_embed(self):
|
||||
"""Test CreateAppPayload rejects object and embed tags."""
|
||||
xss_payloads = [
|
||||
"<object data='evil.swf'></object>",
|
||||
"<embed src='evil.swf'>",
|
||||
"<OBJECT data='javascript:alert(1)'></OBJECT>",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_link_javascript(self):
|
||||
"""Test CreateAppPayload rejects link tags with javascript."""
|
||||
xss_payloads = [
|
||||
"<link href='javascript:alert(1)'>",
|
||||
"<LINK HREF='javascript:alert(1)'>",
|
||||
]
|
||||
|
||||
for name in xss_payloads:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_xss_in_description(self):
|
||||
"""Test CreateAppPayload rejects XSS in description."""
|
||||
xss_descriptions = [
|
||||
"<script>alert(1)</script>",
|
||||
"javascript:alert(1)",
|
||||
"<img onerror=alert(1)>",
|
||||
]
|
||||
|
||||
for description in xss_descriptions:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(
|
||||
name="Valid Name",
|
||||
mode="chat",
|
||||
description=description,
|
||||
)
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_create_app_valid_descriptions(self):
|
||||
"""Test CreateAppPayload with valid descriptions."""
|
||||
valid_descriptions = [
|
||||
"A simple description",
|
||||
"Description with < and > symbols",
|
||||
"Description with & ampersand",
|
||||
"Description with 'quotes' and \"double quotes\"",
|
||||
"Description with / slashes",
|
||||
"Description with \\ backslashes",
|
||||
"Description with ; semicolons",
|
||||
"Unicode: 这是一个描述",
|
||||
"Emoji: 🎉🚀",
|
||||
]
|
||||
|
||||
for description in valid_descriptions:
|
||||
payload = CreateAppPayload(
|
||||
name="Valid App Name",
|
||||
mode="chat",
|
||||
description=description,
|
||||
)
|
||||
assert payload.description == description
|
||||
|
||||
def test_create_app_none_description(self):
|
||||
"""Test CreateAppPayload with None description."""
|
||||
payload = CreateAppPayload(
|
||||
name="Valid App Name",
|
||||
mode="chat",
|
||||
description=None,
|
||||
)
|
||||
assert payload.description is None
|
||||
|
||||
def test_update_app_xss_prevention(self):
|
||||
"""Test UpdateAppPayload also prevents XSS."""
|
||||
xss_names = [
|
||||
"<script>alert(1)</script>",
|
||||
"javascript:alert(1)",
|
||||
"<img onerror=alert(1)>",
|
||||
]
|
||||
|
||||
for name in xss_names:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
UpdateAppPayload(name=name)
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_update_app_valid_names(self):
|
||||
"""Test UpdateAppPayload with valid names."""
|
||||
payload = UpdateAppPayload(name="Valid Updated Name")
|
||||
assert payload.name == "Valid Updated Name"
|
||||
|
||||
def test_copy_app_xss_prevention(self):
|
||||
"""Test CopyAppPayload also prevents XSS."""
|
||||
xss_names = [
|
||||
"<script>alert(1)</script>",
|
||||
"javascript:alert(1)",
|
||||
"<img onerror=alert(1)>",
|
||||
]
|
||||
|
||||
for name in xss_names:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CopyAppPayload(name=name)
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
|
||||
def test_copy_app_valid_names(self):
|
||||
"""Test CopyAppPayload with valid names."""
|
||||
payload = CopyAppPayload(name="Valid Copy Name")
|
||||
assert payload.name == "Valid Copy Name"
|
||||
|
||||
def test_copy_app_none_name(self):
|
||||
"""Test CopyAppPayload with None name (should be allowed)."""
|
||||
payload = CopyAppPayload(name=None)
|
||||
assert payload.name is None
|
||||
|
||||
def test_edge_case_angle_brackets_content(self):
|
||||
"""Test that angle brackets with actual content are rejected."""
|
||||
# Angle brackets without valid HTML-like patterns should be checked
|
||||
# The regex pattern <.*?on\w+\s*= should catch event handlers
|
||||
# But let's verify other patterns too
|
||||
|
||||
# Valid: angle brackets used as symbols (not matched by our patterns)
|
||||
# Our patterns specifically look for dangerous constructs
|
||||
|
||||
# Invalid: actual HTML tags with event handlers
|
||||
invalid_names = [
|
||||
"<div onclick=xss>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
]
|
||||
|
||||
for name in invalid_names:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CreateAppPayload(name=name, mode="chat")
|
||||
assert "invalid characters or patterns" in str(exc_info.value).lower()
|
||||
@@ -7,19 +7,24 @@ export const jsonObjectWrap = {
|
||||
|
||||
export const jsonConfigPlaceHolder = JSON.stringify(
|
||||
{
|
||||
foo: {
|
||||
type: 'string',
|
||||
},
|
||||
bar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: {
|
||||
type: 'number',
|
||||
},
|
||||
type: 'object',
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'string',
|
||||
},
|
||||
bar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInpu
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import { jsonConfigPlaceHolder, jsonObjectWrap } from './config'
|
||||
import { jsonConfigPlaceHolder } from './config'
|
||||
import Field from './field'
|
||||
import TypeSelector from './type-select'
|
||||
|
||||
@@ -78,13 +78,12 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
|
||||
const isSupportJSON = false
|
||||
const jsonSchemaStr = useMemo(() => {
|
||||
const isJsonObject = type === InputVarType.jsonObject
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2)
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
@@ -129,13 +128,14 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
}, [])
|
||||
|
||||
const handleJSONSchemaChange = useCallback((value: string) => {
|
||||
const isEmpty = value == null || value.trim() === ''
|
||||
if (isEmpty) {
|
||||
handlePayloadChange('json_schema')(undefined)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const v = JSON.parse(value)
|
||||
const res = {
|
||||
...jsonObjectWrap,
|
||||
properties: v,
|
||||
}
|
||||
handlePayloadChange('json_schema')(JSON.stringify(res, null, 2))
|
||||
handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
@@ -175,7 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((!isBasicApp && isSupportJSON)
|
||||
...((!isBasicApp)
|
||||
? [{
|
||||
name: t('variableConfig.json', { ns: 'appDebug' }),
|
||||
value: InputVarType.jsonObject,
|
||||
@@ -233,7 +233,28 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
|
||||
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
|
||||
|
||||
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed === ''
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const jsonSchemaValue = tempPayload.json_schema
|
||||
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
|
||||
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
|
||||
|
||||
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
|
||||
// remove the `json_schema` field from the payload by setting its value to `undefined`.
|
||||
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
@@ -250,7 +271,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
return
|
||||
}
|
||||
if (isStringInput || type === InputVarType.number) {
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.select) {
|
||||
if (options?.length === 0) {
|
||||
@@ -270,7 +291,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
if (tempPayload.allowed_file_types?.length === 0) {
|
||||
@@ -283,10 +304,26 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
Toast.notify({ type: 'error', message: errorMessages })
|
||||
return
|
||||
}
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.jsonObject) {
|
||||
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
if (schema?.type !== 'object') {
|
||||
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else {
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ChatConversationGeneralDetail, ChatConversationsResponse } from '@/models/log'
|
||||
import type { App, AppIconType } from '@/types/app'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Logs from './index'
|
||||
|
||||
const mockUseChatConversations = vi.fn()
|
||||
const mockUseCompletionConversations = vi.fn()
|
||||
const mockUseAnnotationsCount = vi.fn()
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockRouterReplace = vi.fn()
|
||||
|
||||
const mockAppStoreState = {
|
||||
setShowPromptLogModal: vi.fn(),
|
||||
setShowAgentLogModal: vi.fn(),
|
||||
setShowMessageLogModal: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
usePathname: () => '/apps/app-123/logs',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversations: (args: unknown) => mockUseChatConversations(args),
|
||||
useCompletionConversations: (args: unknown) => mockUseCompletionConversations(args),
|
||||
useAnnotationsCount: () => mockUseAnnotationsCount(),
|
||||
useChatConversationDetail: () => ({ data: undefined }),
|
||||
useCompletionConversationDetail: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchChatMessages: vi.fn(),
|
||||
updateLogMessageAnnotations: vi.fn(),
|
||||
updateLogMessageFeedbacks: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: { timezone: 'UTC' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState),
|
||||
}))
|
||||
|
||||
const renderWithAdapter = (ui: ReactNode, searchParams = '') => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
{ui}
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
const createChatConversation = (overrides: Partial<ChatConversationGeneralDetail> = {}): ChatConversationGeneralDetail => ({
|
||||
id: 'conversation-1',
|
||||
status: 'normal',
|
||||
from_source: 'api',
|
||||
from_end_user_id: 'user-1',
|
||||
from_end_user_session_id: 'session-1',
|
||||
from_account_id: 'account-1',
|
||||
read_at: new Date(),
|
||||
created_at: 1700000000,
|
||||
updated_at: 1700000001,
|
||||
user_feedback_stats: { like: 0, dislike: 0 },
|
||||
admin_feedback_stats: { like: 0, dislike: 0 },
|
||||
model_config: {
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4',
|
||||
configs: { prompt_template: '' },
|
||||
},
|
||||
summary: 'Conversation summary',
|
||||
message_count: 1,
|
||||
annotated: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createChatConversationsResponse = (overrides: Partial<ChatConversationsResponse> = {}): ChatConversationsResponse => ({
|
||||
data: [createChatConversation()],
|
||||
has_more: false,
|
||||
limit: APP_PAGE_LIMIT,
|
||||
total: 1,
|
||||
page: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Logs page: loading, empty, and data states.
|
||||
describe('Logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.innerWidth = 1024
|
||||
|
||||
mockUseAnnotationsCount.mockReturnValue({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
mockUseCompletionConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
// Loading behavior when no data yet.
|
||||
describe('Rendering', () => {
|
||||
it('should render loading state when conversations are undefined', () => {
|
||||
// Arrange
|
||||
const appDetail = createMockApp()
|
||||
|
||||
// Act
|
||||
renderWithAdapter(<Logs appDetail={appDetail} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty state when there are no conversations', () => {
|
||||
// Arrange
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: createChatConversationsResponse({ data: [], total: 0 }),
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
const appDetail = createMockApp()
|
||||
|
||||
// Act
|
||||
renderWithAdapter(<Logs appDetail={appDetail} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Data rendering behavior.
|
||||
describe('Props', () => {
|
||||
it('should render list with pagination when conversations exist', () => {
|
||||
// Arrange
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: createChatConversationsResponse({ total: APP_PAGE_LIMIT + 1 }),
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
const appDetail = createMockApp()
|
||||
|
||||
// Act
|
||||
renderWithAdapter(<Logs appDetail={appDetail} />, '?page=0&limit=0')
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appLog.table.header.summary')).toBeInTheDocument()
|
||||
expect(screen.getByText('25')).toBeInTheDocument()
|
||||
|
||||
const firstCallArgs = mockUseChatConversations.mock.calls[0]?.[0]
|
||||
expect(firstCallArgs.params.page).toBe(1)
|
||||
expect(firstCallArgs.params.limit).toBe(APP_PAGE_LIMIT)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,9 @@ import type { App } from '@/types/app'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from 'nuqs'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
@@ -32,39 +28,53 @@ export type QueryParam = {
|
||||
sort_by?: string
|
||||
}
|
||||
|
||||
const defaultQueryParams: QueryParam = {
|
||||
period: '2',
|
||||
annotation_status: 'all',
|
||||
sort_by: '-created_at',
|
||||
}
|
||||
|
||||
const logsStateCache = new Map<string, {
|
||||
queryParams: QueryParam
|
||||
currPage: number
|
||||
limit: number
|
||||
}>()
|
||||
|
||||
const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [queryParams, setQueryParams] = useQueryStates(
|
||||
{
|
||||
page: parseAsInteger.withDefault(1),
|
||||
limit: parseAsInteger.withDefault(APP_PAGE_LIMIT),
|
||||
period: parseAsString.withDefault('2'),
|
||||
annotation_status: parseAsString.withDefault('all'),
|
||||
keyword: parseAsString,
|
||||
sort_by: parseAsString.withDefault('-created_at'),
|
||||
},
|
||||
{
|
||||
urlKeys: {
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
period: 'period',
|
||||
annotation_status: 'annotation_status',
|
||||
keyword: 'keyword',
|
||||
sort_by: 'sort_by',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const getPageFromParams = useCallback(() => {
|
||||
const pageParam = Number.parseInt(searchParams.get('page') || '1', 10)
|
||||
if (Number.isNaN(pageParam) || pageParam < 1)
|
||||
return 0
|
||||
return pageParam - 1
|
||||
}, [searchParams])
|
||||
const cachedState = logsStateCache.get(appDetail.id)
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>(cachedState?.queryParams ?? defaultQueryParams)
|
||||
const [currPage, setCurrPage] = React.useState<number>(() => cachedState?.currPage ?? getPageFromParams())
|
||||
const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT)
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const page = queryParams.page > 0 ? queryParams.page : 1
|
||||
const limit = queryParams.limit > 0 ? queryParams.limit : APP_PAGE_LIMIT
|
||||
|
||||
useEffect(() => {
|
||||
const pageFromParams = getPageFromParams()
|
||||
setCurrPage(prev => (prev === pageFromParams ? prev : pageFromParams))
|
||||
}, [getPageFromParams])
|
||||
|
||||
useEffect(() => {
|
||||
logsStateCache.set(appDetail.id, {
|
||||
queryParams,
|
||||
currPage,
|
||||
limit,
|
||||
})
|
||||
}, [appDetail.id, currPage, limit, queryParams])
|
||||
|
||||
// Get the app type first
|
||||
const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION
|
||||
|
||||
const query = {
|
||||
page,
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
...((debouncedQueryParams.period !== '9')
|
||||
? {
|
||||
@@ -73,8 +83,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
}
|
||||
: {}),
|
||||
...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}),
|
||||
...omit(debouncedQueryParams, ['period', 'page', 'limit']),
|
||||
keyword: debouncedQueryParams.keyword || undefined,
|
||||
...omit(debouncedQueryParams, ['period']),
|
||||
}
|
||||
|
||||
// When the details are obtained, proceed to the next request
|
||||
@@ -91,25 +100,27 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||
|
||||
const handleQueryParamsChange = useCallback((next: QueryParam) => {
|
||||
setQueryParams({
|
||||
...next,
|
||||
page: 1, // Reset to page 1 on filter change
|
||||
})
|
||||
}, [setQueryParams])
|
||||
setCurrPage(0)
|
||||
setQueryParams(next)
|
||||
}, [])
|
||||
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setQueryParams({ page: page + 1 })
|
||||
}, [setQueryParams])
|
||||
|
||||
const handleLimitChange = useCallback((limit: number) => {
|
||||
setQueryParams({ limit, page: 1 })
|
||||
}, [setQueryParams])
|
||||
setCurrPage(page)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
const nextPageValue = page + 1
|
||||
if (nextPageValue === 1)
|
||||
params.delete('page')
|
||||
else
|
||||
params.set('page', String(nextPageValue))
|
||||
const queryString = params.toString()
|
||||
router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false })
|
||||
}, [pathname, router, searchParams])
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<p className="system-sm-regular shrink-0 text-text-tertiary">{t('description', { ns: 'appLog' })}</p>
|
||||
<div className="flex max-h-[calc(100%-16px)] flex-1 grow flex-col py-4">
|
||||
<Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={{ ...queryParams, keyword: queryParams.keyword ?? undefined }} setQueryParams={handleQueryParamsChange} />
|
||||
<Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={queryParams} setQueryParams={handleQueryParamsChange} />
|
||||
{total === undefined
|
||||
? <Loading type="app" />
|
||||
: total > 0
|
||||
@@ -119,11 +130,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={page - 1}
|
||||
current={currPage}
|
||||
onChange={handlePageChange}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={handleLimitChange}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ChatConfig } from '../types'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import {
|
||||
fetchChatList,
|
||||
@@ -75,11 +74,9 @@ const createQueryClient = () => new QueryClient({
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -83,10 +82,12 @@ export const useEmbeddedChatbot = () => {
|
||||
setConversationId(embeddedConversationId || undefined)
|
||||
}, [embeddedConversationId])
|
||||
|
||||
const [localeParam] = useQueryState('locale', parseAsString)
|
||||
|
||||
useEffect(() => {
|
||||
const setLanguageFromParams = async () => {
|
||||
// Check URL parameters for language override
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const localeParam = urlParams.get('locale')
|
||||
|
||||
// Check for encoded system variables
|
||||
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
|
||||
const localeFromSysVar = systemVariables.locale
|
||||
@@ -106,7 +107,7 @@ export const useEmbeddedChatbot = () => {
|
||||
}
|
||||
|
||||
setLanguageFromParams()
|
||||
}, [appInfo, localeParam])
|
||||
}, [appInfo])
|
||||
|
||||
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
|
||||
defaultValue: {},
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
let searchParamsValues: Record<string, string | null> = {}
|
||||
const setSearchParams = (values: Record<string, string | null>) => {
|
||||
searchParamsValues = values
|
||||
}
|
||||
|
||||
type PartnerStackGlobal = typeof globalThis & {
|
||||
__partnerStackCookieMocks?: {
|
||||
get: ReturnType<typeof vi.fn>
|
||||
@@ -45,6 +48,11 @@ vi.mock('js-cookie', () => {
|
||||
remove,
|
||||
}
|
||||
})
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => searchParamsValues[key] ?? null,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/service/use-billing', () => {
|
||||
const mutateAsync = vi.fn()
|
||||
const globals = getPartnerStackGlobal()
|
||||
@@ -56,15 +64,6 @@ vi.mock('@/service/use-billing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
return renderHook(() => usePSInfo(), { wrapper })
|
||||
}
|
||||
|
||||
describe('usePSInfo', () => {
|
||||
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
||||
|
||||
@@ -76,6 +75,7 @@ describe('usePSInfo', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setSearchParams({})
|
||||
const { get, set, remove } = ensureCookieMocks()
|
||||
get.mockReset()
|
||||
set.mockReset()
|
||||
@@ -94,7 +94,12 @@ describe('usePSInfo', () => {
|
||||
it('saves partner info when query params change', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
|
||||
const { result } = renderWithAdapter('?ps_partner_key=new-partner&ps_xid=new-click')
|
||||
setSearchParams({
|
||||
ps_partner_key: 'new-partner',
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('new-partner')
|
||||
expect(result.current.psClickId).toBe('new-click')
|
||||
@@ -118,13 +123,17 @@ describe('usePSInfo', () => {
|
||||
})
|
||||
|
||||
it('does not overwrite cookie when params do not change', () => {
|
||||
setSearchParams({
|
||||
ps_partner_key: 'existing',
|
||||
ps_xid: 'existing-click',
|
||||
})
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'existing',
|
||||
clickId: 'existing-click',
|
||||
}))
|
||||
|
||||
const { result } = renderWithAdapter('?ps_partner_key=existing&ps_xid=existing-click')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
@@ -135,7 +144,12 @@ describe('usePSInfo', () => {
|
||||
})
|
||||
|
||||
it('binds partner info and clears cookie once', async () => {
|
||||
const { result } = renderWithAdapter('?ps_partner_key=bind-partner&ps_xid=bind-click')
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
const { remove } = ensureCookieMocks()
|
||||
@@ -162,7 +176,12 @@ describe('usePSInfo', () => {
|
||||
it('still removes cookie when bind fails with status 400', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 400 })
|
||||
const { result } = renderWithAdapter('?ps_partner_key=bind-partner&ps_xid=bind-click')
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Cookies from 'js-cookie'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import { useBindPartnerStackInfo } from '@/service/use-billing'
|
||||
|
||||
const usePSInfo = () => {
|
||||
const [partnerKey] = useQueryState('ps_partner_key', parseAsString)
|
||||
const [clickId] = useQueryState('ps_xid', parseAsString)
|
||||
const searchParams = useSearchParams()
|
||||
const psInfoInCookie = (() => {
|
||||
try {
|
||||
return JSON.parse(Cookies.get(PARTNER_STACK_CONFIG.cookieName) || '{}')
|
||||
@@ -17,8 +16,8 @@ const usePSInfo = () => {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
const psPartnerKey = partnerKey || psInfoInCookie?.partnerKey
|
||||
const psClickId = clickId || psInfoInCookie?.clickId
|
||||
const psPartnerKey = searchParams.get('ps_partner_key') || psInfoInCookie?.partnerKey
|
||||
const psClickId = searchParams.get('ps_xid') || psInfoInCookie?.clickId
|
||||
const isPSChanged = psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId
|
||||
const [hasBind, {
|
||||
setTrue: setBind,
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import useDocumentListQueryState from './use-document-list-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
|
||||
return renderHook(() => useDocumentListQueryState(), { wrapper })
|
||||
}
|
||||
|
||||
// Document list query state: defaults, sanitization, and update actions.
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Default query values.
|
||||
describe('Rendering', () => {
|
||||
it('should return default query values when URL params are missing', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
// Act
|
||||
const { query } = result.current
|
||||
|
||||
// Assert
|
||||
expect(query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// URL sanitization behavior.
|
||||
describe('Edge Cases', () => {
|
||||
it('should sanitize invalid URL query values', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter('?page=0&limit=500&keyword=%20%20&status=invalid&sort=bad')
|
||||
|
||||
// Act
|
||||
const { query } = result.current
|
||||
|
||||
// Assert
|
||||
expect(query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Query update actions.
|
||||
describe('User Interactions', () => {
|
||||
it('should normalize query updates', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.updateQuery({
|
||||
page: 0,
|
||||
limit: 200,
|
||||
keyword: ' search ',
|
||||
status: 'invalid',
|
||||
sort: 'hit_count',
|
||||
})
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: ' search ',
|
||||
status: 'all',
|
||||
sort: 'hit_count',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset query values to defaults', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter('?page=2&limit=25&keyword=hello&status=enabled&sort=hit_count')
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Callback stability.
|
||||
describe('Performance', () => {
|
||||
it('should keep action callbacks stable across updates', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter()
|
||||
const initialUpdate = result.current.updateQuery
|
||||
const initialReset = result.current.resetQuery
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.updateQuery).toBe(initialUpdate)
|
||||
expect(result.current.resetQuery).toBe(initialReset)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReadonlyURLSearchParams } from 'next/navigation'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { sanitizeStatusValue } from '../status-filter'
|
||||
|
||||
@@ -20,14 +21,6 @@ export type DocumentListQuery = {
|
||||
sort: SortType
|
||||
}
|
||||
|
||||
type DocumentListQueryInput = {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string | null
|
||||
status?: string | null
|
||||
sort?: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: DocumentListQuery = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -36,60 +29,89 @@ const DEFAULT_QUERY: DocumentListQuery = {
|
||||
sort: '-created_at',
|
||||
}
|
||||
|
||||
const normalizeKeywordValue = (value?: string | null) => (value && value.trim() ? value : '')
|
||||
|
||||
const normalizeDocumentListQuery = (query: DocumentListQueryInput): DocumentListQuery => {
|
||||
const page = (query.page && query.page > 0) ? query.page : DEFAULT_QUERY.page
|
||||
const limit = (query.limit && query.limit > 0 && query.limit <= 100) ? query.limit : DEFAULT_QUERY.limit
|
||||
const keyword = normalizeKeywordValue(query.keyword ?? DEFAULT_QUERY.keyword)
|
||||
const status = sanitizeStatusValue(query.status ?? DEFAULT_QUERY.status)
|
||||
const sort = sanitizeSortValue(query.sort ?? DEFAULT_QUERY.sort)
|
||||
// Parse the query parameters from the URL search string.
|
||||
function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery {
|
||||
const page = Number.parseInt(params.get('page') || '1', 10)
|
||||
const limit = Number.parseInt(params.get('limit') || '10', 10)
|
||||
const keyword = params.get('keyword') || ''
|
||||
const status = sanitizeStatusValue(params.get('status'))
|
||||
const sort = sanitizeSortValue(params.get('sort'))
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
keyword,
|
||||
page: page > 0 ? page : 1,
|
||||
limit: (limit > 0 && limit <= 100) ? limit : 10,
|
||||
keyword: keyword ? decodeURIComponent(keyword) : '',
|
||||
status,
|
||||
sort,
|
||||
}
|
||||
}
|
||||
|
||||
// Update the URL search string with the given query parameters.
|
||||
function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) {
|
||||
const { page, limit, keyword, status, sort } = query || {}
|
||||
|
||||
const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim())
|
||||
|
||||
if (hasNonDefaultParams) {
|
||||
searchParams.set('page', (page || 1).toString())
|
||||
searchParams.set('limit', (limit || 10).toString())
|
||||
}
|
||||
else {
|
||||
searchParams.delete('page')
|
||||
searchParams.delete('limit')
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim())
|
||||
searchParams.set('keyword', encodeURIComponent(keyword))
|
||||
else
|
||||
searchParams.delete('keyword')
|
||||
|
||||
const sanitizedStatus = sanitizeStatusValue(status)
|
||||
if (sanitizedStatus && sanitizedStatus !== 'all')
|
||||
searchParams.set('status', sanitizedStatus)
|
||||
else
|
||||
searchParams.delete('status')
|
||||
|
||||
const sanitizedSort = sanitizeSortValue(sort)
|
||||
if (sanitizedSort !== '-created_at')
|
||||
searchParams.set('sort', sanitizedSort)
|
||||
else
|
||||
searchParams.delete('sort')
|
||||
}
|
||||
|
||||
function useDocumentListQueryState() {
|
||||
const [query, setQuery] = useQueryStates(
|
||||
{
|
||||
page: parseAsInteger.withDefault(DEFAULT_QUERY.page),
|
||||
limit: parseAsInteger.withDefault(DEFAULT_QUERY.limit),
|
||||
keyword: parseAsString.withDefault(DEFAULT_QUERY.keyword),
|
||||
status: parseAsString.withDefault(DEFAULT_QUERY.status),
|
||||
sort: parseAsString.withDefault(DEFAULT_QUERY.sort),
|
||||
},
|
||||
{
|
||||
history: 'push',
|
||||
urlKeys: {
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
keyword: 'keyword',
|
||||
status: 'status',
|
||||
sort: 'sort',
|
||||
},
|
||||
},
|
||||
)
|
||||
const searchParams = useSearchParams()
|
||||
const query = useMemo(() => parseParams(searchParams), [searchParams])
|
||||
|
||||
const finalQuery = useMemo(() => normalizeDocumentListQuery(query), [query])
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Helper function to update specific query parameters
|
||||
const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => {
|
||||
setQuery(prev => normalizeDocumentListQuery({ ...prev, ...updates }))
|
||||
}, [setQuery])
|
||||
const newQuery = { ...query, ...updates }
|
||||
newQuery.status = sanitizeStatusValue(newQuery.status)
|
||||
newQuery.sort = sanitizeSortValue(newQuery.sort)
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(newQuery, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [query, router, pathname])
|
||||
|
||||
// Helper function to reset query to defaults
|
||||
const resetQuery = useCallback(() => {
|
||||
setQuery(DEFAULT_QUERY)
|
||||
}, [setQuery])
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(DEFAULT_QUERY, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [router, pathname])
|
||||
|
||||
return useMemo(() => ({
|
||||
query: finalQuery,
|
||||
query,
|
||||
updateQuery,
|
||||
resetQuery,
|
||||
}), [finalQuery, updateQuery, resetQuery])
|
||||
}), [query, updateQuery, resetQuery])
|
||||
}
|
||||
|
||||
export default useDocumentListQueryState
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
|
||||
import NormalForm from './normal-form'
|
||||
import OneMoreStep from './one-more-step'
|
||||
|
||||
const SignIn = () => {
|
||||
const [step] = useQueryState('step', parseAsString)
|
||||
const searchParams = useSearchParams()
|
||||
const step = searchParams.get('step')
|
||||
const { saveOrUpdate } = usePSInfo()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -134,7 +134,7 @@ export const ProviderContextProvider = ({
|
||||
|
||||
const [enableEducationPlan, setEnableEducationPlan] = useState(false)
|
||||
const [isEducationWorkspace, setIsEducationWorkspace] = useState(false)
|
||||
const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo } = useEducationStatus(!enableEducationPlan)
|
||||
const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo, isFetchedAfterMount: isEducationDataFetchedAfterMount } = useEducationStatus(!enableEducationPlan)
|
||||
const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false)
|
||||
const [isAllowPublishAsCustomKnowledgePipelineTemplate, setIsAllowPublishAsCustomKnowledgePipelineTemplate] = useState(false)
|
||||
|
||||
@@ -240,9 +240,9 @@ export const ProviderContextProvider = ({
|
||||
datasetOperatorEnabled,
|
||||
enableEducationPlan,
|
||||
isEducationWorkspace,
|
||||
isEducationAccount: educationAccountInfo?.is_student || false,
|
||||
allowRefreshEducationVerify: educationAccountInfo?.allow_refresh || false,
|
||||
educationAccountExpireAt: educationAccountInfo?.expire_at || null,
|
||||
isEducationAccount: isEducationDataFetchedAfterMount ? (educationAccountInfo?.is_student ?? false) : false,
|
||||
allowRefreshEducationVerify: isEducationDataFetchedAfterMount ? (educationAccountInfo?.allow_refresh ?? false) : false,
|
||||
educationAccountExpireAt: isEducationDataFetchedAfterMount ? (educationAccountInfo?.expire_at ?? null) : null,
|
||||
isLoadingEducationAccountInfo,
|
||||
isFetchingEducationAccountInfo,
|
||||
webappCopyrightEnabled,
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "اسم العرض",
|
||||
"variableConfig.editModalTitle": "تعديل حقل إدخال",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "خيار واحد على الأقل مطلوب",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "مخطط JSON ليس JSON صالحًا",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "يجب أن يكون نوع مخطط JSON \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "اسم التسمية مطلوب",
|
||||
"variableConfig.errorMsg.optionRepeat": "يوجد خيارات مكررة",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "اسم المتغير لا يمكن تكراره",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Anzeigename",
|
||||
"variableConfig.editModalTitle": "Eingabefeld bearbeiten",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Mindestens eine Option ist erforderlich",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON-Schema ist kein gültiges JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON-Schema muss den Typ \"object\" haben",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Labelname ist erforderlich",
|
||||
"variableConfig.errorMsg.optionRepeat": "Hat Wiederholungsoptionen",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Variablenname kann nicht wiederholt werden",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Display Name",
|
||||
"variableConfig.editModalTitle": "Edit Input Field",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "At least one option is required",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema is not valid JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema must have type \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Label name is required",
|
||||
"variableConfig.errorMsg.optionRepeat": "Has repeat options",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nombre para mostrar",
|
||||
"variableConfig.editModalTitle": "Editar Campo de Entrada",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Se requiere al menos una opción",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "El esquema JSON no es un JSON válido",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "El esquema JSON debe tener el tipo \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Nombre de la etiqueta es requerido",
|
||||
"variableConfig.errorMsg.optionRepeat": "Hay opciones repetidas",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "El nombre de la variable no puede repetirse",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "نام نمایشی",
|
||||
"variableConfig.editModalTitle": "ویرایش فیلد ورودی",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "حداقل یک گزینه مورد نیاز است",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema یک JSON معتبر نیست",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "نوع JSON Schema باید \"object\" باشد",
|
||||
"variableConfig.errorMsg.labelNameRequired": "نام برچسب الزامی است",
|
||||
"variableConfig.errorMsg.optionRepeat": "دارای گزینه های تکرار",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "نام متغیر را نمی توان تکرار کرد",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nom d’affichage",
|
||||
"variableConfig.editModalTitle": "Edit Input Field",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "At least one option is required",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "Le schéma JSON n’est pas un JSON valide",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Le schéma JSON doit avoir le type \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Label name is required",
|
||||
"variableConfig.errorMsg.optionRepeat": "Has repeat options",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "प्रदर्शन नाम",
|
||||
"variableConfig.editModalTitle": "इनपुट फ़ील्ड संपादित करें",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "कम से कम एक विकल्प आवश्यक है",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON स्कीमा मान्य JSON नहीं है",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON स्कीमा का प्रकार \"object\" होना चाहिए",
|
||||
"variableConfig.errorMsg.labelNameRequired": "लेबल नाम आवश्यक है",
|
||||
"variableConfig.errorMsg.optionRepeat": "विकल्प दोहराए गए हैं",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "वेरिएबल नाम दोहराया नहीं जा सकता",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nama Tampilan",
|
||||
"variableConfig.editModalTitle": "Edit Bidang Input",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Setidaknya satu opsi diperlukan",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema bukan JSON yang valid",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema harus bertipe \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Nama label diperlukan",
|
||||
"variableConfig.errorMsg.optionRepeat": "Memiliki opsi pengulangan",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Nama variabel tidak dapat diulang",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nome visualizzato",
|
||||
"variableConfig.editModalTitle": "Modifica Campo Input",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "È richiesta almeno un'opzione",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "Lo schema JSON non è un JSON valido",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Lo schema JSON deve avere tipo \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Il nome dell'etichetta è richiesto",
|
||||
"variableConfig.errorMsg.optionRepeat": "Ci sono opzioni ripetute",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Il nome della variabile non può essere ripetuto",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "表示名",
|
||||
"variableConfig.editModalTitle": "入力フィールドを編集",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "少なくとも 1 つのオプションが必要です",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSONスキーマが有効なJSONではありません",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSONスキーマのtypeは\"object\"である必要があります",
|
||||
"variableConfig.errorMsg.labelNameRequired": "ラベル名は必須です",
|
||||
"variableConfig.errorMsg.optionRepeat": "繰り返しオプションがあります",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "変数名は繰り返すことができません",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "표시 이름",
|
||||
"variableConfig.editModalTitle": "입력 필드 편집",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "적어도 하나의 옵션이 필요합니다",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON 스키마가 올바른 JSON이 아닙니다",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON 스키마의 type은 \"object\"이어야 합니다",
|
||||
"variableConfig.errorMsg.labelNameRequired": "레이블명은 필수입니다",
|
||||
"variableConfig.errorMsg.optionRepeat": "옵션이 중복되어 있습니다",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "변수명은 중복될 수 없습니다",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nazwa wyświetlana",
|
||||
"variableConfig.editModalTitle": "Edytuj Pole Wejściowe",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Wymagana jest co najmniej jedna opcja",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "Schemat JSON nie jest prawidłowym JSON-em",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Schemat JSON musi mieć typ \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Wymagana nazwa etykiety",
|
||||
"variableConfig.errorMsg.optionRepeat": "Powtarzają się opcje",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Nazwa zmiennej nie może się powtarzać",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nome de exibição",
|
||||
"variableConfig.editModalTitle": "Editar Campo de Entrada",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Pelo menos uma opção é obrigatória",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "O JSON Schema não é um JSON válido",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "O JSON Schema deve ter o tipo \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "O nome do rótulo é obrigatório",
|
||||
"variableConfig.errorMsg.optionRepeat": "Tem opções repetidas",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "O nome da variável não pode ser repetido",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Nume afișat",
|
||||
"variableConfig.editModalTitle": "Editați câmpul de intrare",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Este necesară cel puțin o opțiune",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "Schema JSON nu este un JSON valid",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Schema JSON trebuie să aibă tipul \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Numele etichetei este obligatoriu",
|
||||
"variableConfig.errorMsg.optionRepeat": "Există opțiuni repetate",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Numele variabilei nu poate fi repetat",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Отображаемое имя",
|
||||
"variableConfig.editModalTitle": "Редактировать поле ввода",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Требуется хотя бы один вариант",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema не является корректным JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema должна иметь тип \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Имя метки обязательно",
|
||||
"variableConfig.errorMsg.optionRepeat": "Есть повторяющиеся варианты",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Имя переменной не может повторяться",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Prikazno ime",
|
||||
"variableConfig.editModalTitle": "Uredi vnosno polje",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Potrebna je vsaj ena možnost",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "Shema JSON ni veljaven JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Shema JSON mora imeti tip \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Ime nalepke je obvezno",
|
||||
"variableConfig.errorMsg.optionRepeat": "Ima možnosti ponavljanja",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Imena spremenljivke ni mogoče ponoviti",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "ชื่อที่แสดง",
|
||||
"variableConfig.editModalTitle": "แก้ไขฟิลด์อินพุต",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "จําเป็นต้องมีอย่างน้อยหนึ่งตัวเลือก",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema ไม่ใช่ JSON ที่ถูกต้อง",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema ต้องมีชนิดเป็น \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "ต้องมีชื่อฉลาก",
|
||||
"variableConfig.errorMsg.optionRepeat": "มีตัวเลือกการทําซ้ํา",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "ไม่สามารถทําซ้ําชื่อตัวแปรได้",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Görünen Ad",
|
||||
"variableConfig.editModalTitle": "Giriş Alanı Düzenle",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "En az bir seçenek gereklidir",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Şeması geçerli bir JSON değil",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Şeması’nın türü \"object\" olmalı",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Etiket adı gereklidir",
|
||||
"variableConfig.errorMsg.optionRepeat": "Yinelenen seçenekler var",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Değişken adı tekrar edemez",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Відображуване ім'я",
|
||||
"variableConfig.editModalTitle": "Редагувати Поле Введення",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Потрібно щонайменше одну опцію",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema не є коректним JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema має мати тип \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Потрібно вказати назву мітки",
|
||||
"variableConfig.errorMsg.optionRepeat": "Є повторні опції",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Назва змінної не може повторюватися",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "Tên hiển thị",
|
||||
"variableConfig.editModalTitle": "Chỉnh sửa trường nhập",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "Cần ít nhất một tùy chọn",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema không phải là JSON hợp lệ",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema phải có kiểu \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Tên nhãn là bắt buộc",
|
||||
"variableConfig.errorMsg.optionRepeat": "Có các tùy chọn trùng lặp",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Tên biến không được trùng lặp",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "显示名称",
|
||||
"variableConfig.editModalTitle": "编辑变量",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "至少需要一个选项",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema 不是合法的 JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema 的 type 必须为 \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "显示名称必填",
|
||||
"variableConfig.errorMsg.optionRepeat": "选项不能重复",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "变量名称不能重复",
|
||||
|
||||
@@ -306,6 +306,8 @@
|
||||
"variableConfig.displayName": "顯示名稱",
|
||||
"variableConfig.editModalTitle": "編輯變數",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "至少需要一個選項",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema 不是合法的 JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema 的 type 必須為「object」",
|
||||
"variableConfig.errorMsg.labelNameRequired": "顯示名稱必填",
|
||||
"variableConfig.errorMsg.optionRepeat": "選項不能重複",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "變數名稱不能重複",
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useEducationStatus = (disable?: boolean) => {
|
||||
return get<{ is_student: boolean, allow_refresh: boolean, expire_at: number | null }>('/account/education')
|
||||
},
|
||||
retry: false,
|
||||
gcTime: 0, // No cache. Prevent switch account caused stale data
|
||||
staleTime: 0, // Data expires immediately, ensuring fresh data on refetch
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,51 @@ describe('useShareChatList', () => {
|
||||
})
|
||||
expect(mockFetchChatList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should always consider data stale to ensure fresh data on conversation switch (GitHub #30378)', async () => {
|
||||
// This test verifies that chat list data is always considered stale (staleTime: 0)
|
||||
// which ensures fresh data is fetched when switching back to a conversation.
|
||||
// Without this, users would see outdated messages until double-switching.
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = createWrapper(queryClient)
|
||||
const params = {
|
||||
conversationId: 'conversation-1',
|
||||
isInstalledApp: false,
|
||||
appId: undefined,
|
||||
}
|
||||
const initialResponse = { data: [{ id: '1', content: 'initial' }] }
|
||||
const updatedResponse = { data: [{ id: '1', content: 'initial' }, { id: '2', content: 'new message' }] }
|
||||
|
||||
// First fetch
|
||||
mockFetchChatList.mockResolvedValueOnce(initialResponse)
|
||||
const { result, unmount } = renderHook(() => useShareChatList(params), { wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(initialResponse)
|
||||
})
|
||||
expect(mockFetchChatList).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Unmount (simulates switching away from conversation)
|
||||
unmount()
|
||||
|
||||
// Remount with same params (simulates switching back)
|
||||
// With staleTime: 0, this should trigger a background refetch
|
||||
mockFetchChatList.mockResolvedValueOnce(updatedResponse)
|
||||
const { result: result2 } = renderHook(() => useShareChatList(params), { wrapper })
|
||||
|
||||
// Should immediately return cached data
|
||||
expect(result2.current.data).toEqual(initialResponse)
|
||||
|
||||
// Should trigger background refetch due to staleTime: 0
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatList).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Should update with fresh data
|
||||
await waitFor(() => {
|
||||
expect(result2.current.data).toEqual(updatedResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: conversation name queries follow enabled flags and installation constraints.
|
||||
|
||||
@@ -122,6 +122,10 @@ export const useShareChatList = (params: ShareChatListParams, options: ShareQuer
|
||||
enabled: isEnabled,
|
||||
refetchOnReconnect,
|
||||
refetchOnWindowFocus,
|
||||
// Always consider chat list data stale to ensure fresh data when switching
|
||||
// back to a conversation. This fixes issue where recent messages don't appear
|
||||
// until switching away and back again (GitHub issue #30378).
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user