mirror of
https://github.com/langgenius/dify.git
synced 2026-03-29 11:56:50 +00:00
Compare commits
60 Commits
34028
...
feat/evalu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e9316de3 | ||
|
|
a1171877a4 | ||
|
|
f06cc339cc | ||
|
|
6bf8982559 | ||
|
|
364d7ebc40 | ||
|
|
7cc81e9a43 | ||
|
|
3409c519e2 | ||
|
|
5851b42af3 | ||
|
|
c5eae67ac9 | ||
|
|
559d326cbd | ||
|
|
abedf2506f | ||
|
|
d01428b5bc | ||
|
|
0de1f17e5c | ||
|
|
17d07a5a43 | ||
|
|
3bdbea99a3 | ||
|
|
b7683aedb1 | ||
|
|
515036e758 | ||
|
|
22b382527f | ||
|
|
2cfe4b5b86 | ||
|
|
6876c8041c | ||
|
|
7de45584ce | ||
|
|
5572d7c7e8 | ||
|
|
db0a2fe52e | ||
|
|
f0ae8d6167 | ||
|
|
2514e181ba | ||
|
|
be2e6e9a14 | ||
|
|
875e2eac1b | ||
|
|
c3c73ceb1f | ||
|
|
6318bf0a2a | ||
|
|
5e1f252046 | ||
|
|
df3b960505 | ||
|
|
26bc108bf1 | ||
|
|
a5cff32743 | ||
|
|
d418dd8eec | ||
|
|
61702fe346 | ||
|
|
43f0c780c3 | ||
|
|
30ebf2bfa9 | ||
|
|
7e3027b5f7 | ||
|
|
b3acf83090 | ||
|
|
36c3d6e48a | ||
|
|
f782ac6b3c | ||
|
|
feef2dd1fa | ||
|
|
a716d8789d | ||
|
|
6816f89189 | ||
|
|
bfcac64a9d | ||
|
|
664eb601a2 | ||
|
|
8e5cc4e0aa | ||
|
|
9f28575903 | ||
|
|
4b9a26a5e6 | ||
|
|
7b85adf1cc | ||
|
|
c964708ebe | ||
|
|
883eb498c0 | ||
|
|
4d3738d225 | ||
|
|
dd0dee739d | ||
|
|
4d19914fcb | ||
|
|
887c7710e9 | ||
|
|
7a722773c7 | ||
|
|
a763aff58b | ||
|
|
c1011f4e5c | ||
|
|
f7afa103a5 |
13
.github/workflows/pyrefly-diff.yml
vendored
13
.github/workflows/pyrefly-diff.yml
vendored
@@ -50,17 +50,6 @@ jobs:
|
||||
run: |
|
||||
diff -u /tmp/pyrefly_base.txt /tmp/pyrefly_pr.txt > pyrefly_diff.txt || true
|
||||
|
||||
- name: Check if line counts match
|
||||
id: line_count_check
|
||||
run: |
|
||||
base_lines=$(wc -l < /tmp/pyrefly_base.txt)
|
||||
pr_lines=$(wc -l < /tmp/pyrefly_pr.txt)
|
||||
if [ "$base_lines" -eq "$pr_lines" ]; then
|
||||
echo "same=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "same=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Save PR number
|
||||
run: |
|
||||
echo ${{ github.event.pull_request.number }} > pr_number.txt
|
||||
@@ -74,7 +63,7 @@ jobs:
|
||||
pr_number.txt
|
||||
|
||||
- name: Comment PR with pyrefly diff
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.line_count_check.outputs.same == 'false' }}
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/style.yml
vendored
2
.github/workflows/style.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Run Type Checks
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: make type-check
|
||||
run: make type-check-core
|
||||
|
||||
- name: Dotenv check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
5
.github/workflows/web-tests.yml
vendored
5
.github/workflows/web-tests.yml
vendored
@@ -22,8 +22,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6]
|
||||
shardTotal: [6]
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -66,7 +66,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
|
||||
7
Makefile
7
Makefile
@@ -74,6 +74,12 @@ type-check:
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Type checks complete"
|
||||
|
||||
type-check-core:
|
||||
@echo "📝 Running core type checks (basedpyright + mypy)..."
|
||||
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Core type checks complete"
|
||||
|
||||
test:
|
||||
@echo "🧪 Running backend unit tests..."
|
||||
@if [ -n "$(TARGET_TESTS)" ]; then \
|
||||
@@ -133,6 +139,7 @@ help:
|
||||
@echo " make check - Check code with ruff"
|
||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
|
||||
@echo " make type-check-core - Run core type checks (basedpyright, mypy)"
|
||||
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||
@echo ""
|
||||
@echo "Docker Build Targets:"
|
||||
|
||||
@@ -127,7 +127,8 @@ ALIYUN_OSS_AUTH_VERSION=v1
|
||||
ALIYUN_OSS_REGION=your-region
|
||||
# Don't start with '/'. OSS doesn't support leading slash in object names.
|
||||
ALIYUN_OSS_PATH=your-path
|
||||
ALIYUN_CLOUDBOX_ID=your-cloudbox-id
|
||||
# Optional CloudBox ID for Aliyun OSS, DO NOT enable it if you are not using CloudBox.
|
||||
#ALIYUN_CLOUDBOX_ID=your-cloudbox-id
|
||||
|
||||
# Google Storage configuration
|
||||
GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name
|
||||
|
||||
@@ -8,6 +8,7 @@ Go admin-api caller.
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
@@ -87,7 +88,7 @@ class EnterpriseAppDSLExport(Resource):
|
||||
"""Export an app's DSL as YAML."""
|
||||
include_secret = request.args.get("include_secret", "false").lower() == "true"
|
||||
|
||||
app_model = db.session.query(App).filter_by(id=app_id).first()
|
||||
app_model = db.session.get(App, app_id)
|
||||
if not app_model:
|
||||
return {"message": "app not found"}, 404
|
||||
|
||||
@@ -104,7 +105,7 @@ def _get_active_account(email: str) -> Account | None:
|
||||
|
||||
Workspace membership is already validated by the Go admin-api caller.
|
||||
"""
|
||||
account = db.session.query(Account).filter_by(email=email).first()
|
||||
account = db.session.scalar(select(Account).where(Account.email == email).limit(1))
|
||||
if account is None or account.status != AccountStatus.ACTIVE:
|
||||
return None
|
||||
return account
|
||||
|
||||
@@ -18,7 +18,7 @@ from graphon.model_runtime.entities import (
|
||||
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent, PromptMessageContentUnionTypes
|
||||
from graphon.model_runtime.entities.model_entities import ModelFeature
|
||||
from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from core.agent.entities import AgentEntity, AgentToolEntity
|
||||
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
|
||||
@@ -104,11 +104,14 @@ class BaseAgentRunner(AppRunner):
|
||||
)
|
||||
# get how many agent thoughts have been created
|
||||
self.agent_thought_count = (
|
||||
db.session.query(MessageAgentThought)
|
||||
.where(
|
||||
MessageAgentThought.message_id == self.message.id,
|
||||
db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(MessageAgentThought)
|
||||
.where(
|
||||
MessageAgentThought.message_id == self.message.id,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
or 0
|
||||
)
|
||||
db.session.close()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -70,23 +70,21 @@ class DatasetIndexToolCallbackHandler:
|
||||
)
|
||||
child_chunk = db.session.scalar(child_chunk_stmt)
|
||||
if child_chunk:
|
||||
_ = (
|
||||
db.session.query(DocumentSegment)
|
||||
db.session.execute(
|
||||
update(DocumentSegment)
|
||||
.where(DocumentSegment.id == child_chunk.segment_id)
|
||||
.update(
|
||||
{DocumentSegment.hit_count: DocumentSegment.hit_count + 1}, synchronize_session=False
|
||||
)
|
||||
.values(hit_count=DocumentSegment.hit_count + 1)
|
||||
)
|
||||
else:
|
||||
query = db.session.query(DocumentSegment).where(
|
||||
DocumentSegment.index_node_id == document.metadata["doc_id"]
|
||||
)
|
||||
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
|
||||
|
||||
if "dataset_id" in document.metadata:
|
||||
query = query.where(DocumentSegment.dataset_id == document.metadata["dataset_id"])
|
||||
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
|
||||
|
||||
# add hit count to document segment
|
||||
query.update({DocumentSegment.hit_count: DocumentSegment.hit_count + 1}, synchronize_session=False)
|
||||
db.session.execute(
|
||||
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ def encrypt_token(tenant_id: str, token: str):
|
||||
from extensions.ext_database import db
|
||||
from models.account import Tenant
|
||||
|
||||
if not (tenant := db.session.query(Tenant).where(Tenant.id == tenant_id).first()):
|
||||
if not (tenant := db.session.get(Tenant, tenant_id)):
|
||||
raise ValueError(f"Tenant with id {tenant_id} not found")
|
||||
assert tenant.encrypt_public_key is not None
|
||||
encrypted_token = rsa.encrypt(token, tenant.encrypt_public_key)
|
||||
|
||||
@@ -10,6 +10,7 @@ from graphon.model_runtime.entities.llm_entities import LLMResult
|
||||
from graphon.model_runtime.entities.message_entities import PromptMessage, SystemPromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
@@ -410,8 +411,8 @@ class LLMGenerator:
|
||||
model_config: ModelConfig,
|
||||
ideal_output: str | None,
|
||||
):
|
||||
last_run: Message | None = (
|
||||
db.session.query(Message).where(Message.app_id == flow_id).order_by(Message.created_at.desc()).first()
|
||||
last_run: Message | None = db.session.scalar(
|
||||
select(Message).where(Message.app_id == flow_id).order_by(Message.created_at.desc()).limit(1)
|
||||
)
|
||||
if not last_run:
|
||||
return LLMGenerator.__instruction_modify_common(
|
||||
|
||||
@@ -227,7 +227,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
|
||||
get app
|
||||
"""
|
||||
try:
|
||||
app = db.session.query(App).where(App.id == app_id).where(App.tenant_id == tenant_id).first()
|
||||
app = db.session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1))
|
||||
except Exception:
|
||||
raise ValueError("app not found")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from core.tools.__base.tool_provider import ToolProviderController
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
@@ -31,7 +31,7 @@ class ToolLabelManager:
|
||||
raise ValueError("Unsupported tool type")
|
||||
|
||||
# delete old labels
|
||||
db.session.query(ToolLabelBinding).where(ToolLabelBinding.tool_id == provider_id).delete()
|
||||
db.session.execute(delete(ToolLabelBinding).where(ToolLabelBinding.tool_id == provider_id))
|
||||
|
||||
# insert new labels
|
||||
for label in labels:
|
||||
|
||||
@@ -255,11 +255,11 @@ class ToolManager:
|
||||
if builtin_provider is None:
|
||||
raise ToolProviderNotFoundError(f"no default provider for {provider_id}")
|
||||
else:
|
||||
builtin_provider = (
|
||||
db.session.query(BuiltinToolProvider)
|
||||
builtin_provider = db.session.scalar(
|
||||
select(BuiltinToolProvider)
|
||||
.where(BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_id))
|
||||
.order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc())
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if builtin_provider is None:
|
||||
@@ -818,13 +818,13 @@ class ToolManager:
|
||||
|
||||
:return: the provider controller, the credentials
|
||||
"""
|
||||
provider: ApiToolProvider | None = (
|
||||
db.session.query(ApiToolProvider)
|
||||
provider: ApiToolProvider | None = db.session.scalar(
|
||||
select(ApiToolProvider)
|
||||
.where(
|
||||
ApiToolProvider.id == provider_id,
|
||||
ApiToolProvider.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if provider is None:
|
||||
@@ -872,13 +872,13 @@ class ToolManager:
|
||||
get api provider
|
||||
"""
|
||||
provider_name = provider
|
||||
provider_obj: ApiToolProvider | None = (
|
||||
db.session.query(ApiToolProvider)
|
||||
provider_obj: ApiToolProvider | None = db.session.scalar(
|
||||
select(ApiToolProvider)
|
||||
.where(
|
||||
ApiToolProvider.tenant_id == tenant_id,
|
||||
ApiToolProvider.name == provider,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if provider_obj is None:
|
||||
@@ -964,10 +964,10 @@ class ToolManager:
|
||||
@classmethod
|
||||
def generate_workflow_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict:
|
||||
try:
|
||||
workflow_provider: WorkflowToolProvider | None = (
|
||||
db.session.query(WorkflowToolProvider)
|
||||
workflow_provider: WorkflowToolProvider | None = db.session.scalar(
|
||||
select(WorkflowToolProvider)
|
||||
.where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if workflow_provider is None:
|
||||
@@ -981,10 +981,10 @@ class ToolManager:
|
||||
@classmethod
|
||||
def generate_api_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict:
|
||||
try:
|
||||
api_provider: ApiToolProvider | None = (
|
||||
db.session.query(ApiToolProvider)
|
||||
api_provider: ApiToolProvider | None = db.session.scalar(
|
||||
select(ApiToolProvider)
|
||||
.where(ApiToolProvider.tenant_id == tenant_id, ApiToolProvider.id == provider_id)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if api_provider is None:
|
||||
|
||||
@@ -110,7 +110,7 @@ class DatasetMultiRetrieverTool(DatasetRetrieverBaseTool):
|
||||
context_list: list[RetrievalSourceMetadata] = []
|
||||
resource_number = 1
|
||||
for segment in sorted_segments:
|
||||
dataset = db.session.query(Dataset).filter_by(id=segment.dataset_id).first()
|
||||
dataset = db.session.get(Dataset, segment.dataset_id)
|
||||
document_stmt = select(Document).where(
|
||||
Document.id == segment.document_id,
|
||||
Document.enabled == True,
|
||||
|
||||
@@ -205,7 +205,7 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool):
|
||||
if self.return_resource:
|
||||
for record in records:
|
||||
segment = record.segment
|
||||
dataset = db.session.query(Dataset).filter_by(id=segment.dataset_id).first()
|
||||
dataset = db.session.get(Dataset, segment.dataset_id)
|
||||
dataset_document_stmt = select(DatasetDocument).where(
|
||||
DatasetDocument.id == segment.document_id,
|
||||
DatasetDocument.enabled == True,
|
||||
|
||||
@@ -35,15 +35,13 @@ class ApiKeyAuthService:
|
||||
|
||||
@staticmethod
|
||||
def get_auth_credentials(tenant_id: str, category: str, provider: str):
|
||||
data_source_api_key_bindings = (
|
||||
db.session.query(DataSourceApiKeyAuthBinding)
|
||||
.where(
|
||||
data_source_api_key_bindings = db.session.scalar(
|
||||
select(DataSourceApiKeyAuthBinding).where(
|
||||
DataSourceApiKeyAuthBinding.tenant_id == tenant_id,
|
||||
DataSourceApiKeyAuthBinding.category == category,
|
||||
DataSourceApiKeyAuthBinding.provider == provider,
|
||||
DataSourceApiKeyAuthBinding.disabled.is_(False),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not data_source_api_key_bindings:
|
||||
return None
|
||||
@@ -54,10 +52,11 @@ class ApiKeyAuthService:
|
||||
|
||||
@staticmethod
|
||||
def delete_provider_auth(tenant_id: str, binding_id: str):
|
||||
data_source_api_key_binding = (
|
||||
db.session.query(DataSourceApiKeyAuthBinding)
|
||||
.where(DataSourceApiKeyAuthBinding.tenant_id == tenant_id, DataSourceApiKeyAuthBinding.id == binding_id)
|
||||
.first()
|
||||
data_source_api_key_binding = db.session.scalar(
|
||||
select(DataSourceApiKeyAuthBinding).where(
|
||||
DataSourceApiKeyAuthBinding.tenant_id == tenant_id,
|
||||
DataSourceApiKeyAuthBinding.id == binding_id,
|
||||
)
|
||||
)
|
||||
if data_source_api_key_binding:
|
||||
db.session.delete(data_source_api_key_binding)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from unittest.mock import patch
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -534,3 +536,283 @@ class TestWorkspaceService:
|
||||
# Verify database state
|
||||
db_session_with_containers.refresh(tenant)
|
||||
assert tenant.id is not None
|
||||
|
||||
def test_get_tenant_info_should_raise_assertion_when_join_missing(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""TenantAccountJoin must exist; missing join should raise AssertionError."""
|
||||
fake = Faker()
|
||||
account = Account(email=fake.email(), name=fake.name(), interface_language="en-US", status="active")
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
tenant = Tenant(name=fake.company(), status="normal", plan="basic")
|
||||
db_session_with_containers.add(tenant)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# No TenantAccountJoin created
|
||||
with patch("services.workspace_service.current_user", account):
|
||||
with pytest.raises(AssertionError, match="TenantAccountJoin not found"):
|
||||
WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
def test_get_tenant_info_should_set_replace_webapp_logo_to_none_when_flag_absent(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""replace_webapp_logo should be None when custom_config_dict does not have the key."""
|
||||
import json
|
||||
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
tenant.custom_config = json.dumps({})
|
||||
db_session_with_containers.commit()
|
||||
|
||||
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = True
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = True
|
||||
|
||||
with patch("services.workspace_service.current_user", account):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["custom_config"]["replace_webapp_logo"] is None
|
||||
|
||||
def test_get_tenant_info_should_use_files_url_for_logo_url(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""The logo URL should use dify_config.FILES_URL as the base."""
|
||||
import json
|
||||
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
tenant.custom_config = json.dumps({"replace_webapp_logo": True})
|
||||
db_session_with_containers.commit()
|
||||
|
||||
custom_base = "https://cdn.mycompany.io"
|
||||
mock_external_service_dependencies["dify_config"].FILES_URL = custom_base
|
||||
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = True
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = True
|
||||
|
||||
with patch("services.workspace_service.current_user", account):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["custom_config"]["replace_webapp_logo"].startswith(custom_base)
|
||||
|
||||
def test_get_tenant_info_should_not_include_cloud_fields_in_self_hosted(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""next_credit_reset_date and trial_credits should NOT appear in SELF_HOSTED mode."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "SELF_HOSTED"
|
||||
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
with patch("services.workspace_service.current_user", account):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert "next_credit_reset_date" not in result
|
||||
assert "trial_credits" not in result
|
||||
assert "trial_credits_used" not in result
|
||||
|
||||
def test_get_tenant_info_cloud_credit_reset_date(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""next_credit_reset_date should be present in CLOUD edition."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=None),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["next_credit_reset_date"] == "2025-02-01"
|
||||
|
||||
def test_get_tenant_info_cloud_paid_pool_not_full(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""trial_credits come from paid pool when plan is not sandbox and pool is not full."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
paid_pool = MagicMock(quota_limit=1000, quota_used=200)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=paid_pool),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 1000
|
||||
assert result["trial_credits_used"] == 200
|
||||
|
||||
def test_get_tenant_info_cloud_paid_pool_unlimited(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""quota_limit == -1 means unlimited; service should use paid pool."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
paid_pool = MagicMock(quota_limit=-1, quota_used=999)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", side_effect=[paid_pool, None]),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == -1
|
||||
assert result["trial_credits_used"] == 999
|
||||
|
||||
def test_get_tenant_info_cloud_fall_back_to_trial_when_paid_full(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""When paid pool is exhausted, switch to trial pool."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
paid_pool = MagicMock(quota_limit=500, quota_used=500)
|
||||
trial_pool = MagicMock(quota_limit=100, quota_used=10)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", side_effect=[paid_pool, trial_pool]),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 100
|
||||
assert result["trial_credits_used"] == 10
|
||||
|
||||
def test_get_tenant_info_cloud_fall_back_to_trial_when_paid_none(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""When paid_pool is None, fall back to trial pool."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
trial_pool = MagicMock(quota_limit=50, quota_used=5)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", side_effect=[None, trial_pool]),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 50
|
||||
assert result["trial_credits_used"] == 5
|
||||
|
||||
def test_get_tenant_info_cloud_sandbox_uses_trial_pool(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""When plan is SANDBOX, skip paid pool and use trial pool."""
|
||||
from enums.cloud_plan import CloudPlan
|
||||
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = CloudPlan.SANDBOX
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
paid_pool = MagicMock(quota_limit=1000, quota_used=0)
|
||||
trial_pool = MagicMock(quota_limit=200, quota_used=20)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", side_effect=[paid_pool, trial_pool]),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 200
|
||||
assert result["trial_credits_used"] == 20
|
||||
|
||||
def test_get_tenant_info_cloud_both_pools_none(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""When both paid and trial pools are absent, trial_credits should not be set."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.next_credit_reset_date = "2025-02-01"
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.current_user", account),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", side_effect=[None, None]),
|
||||
):
|
||||
result = WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
assert result is not None
|
||||
assert "trial_credits" not in result
|
||||
assert "trial_credits_used" not in result
|
||||
|
||||
@@ -64,18 +64,18 @@ class TestGetActiveAccount:
|
||||
def test_returns_active_account(self, mock_db):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = "active"
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
|
||||
mock_db.session.scalar.return_value = mock_account
|
||||
|
||||
result = _get_active_account("user@example.com")
|
||||
|
||||
assert result is mock_account
|
||||
mock_db.session.query.return_value.filter_by.assert_called_once_with(email="user@example.com")
|
||||
mock_db.session.scalar.assert_called_once()
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_none_for_inactive_account(self, mock_db):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = "banned"
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
|
||||
mock_db.session.scalar.return_value = mock_account
|
||||
|
||||
result = _get_active_account("banned@example.com")
|
||||
|
||||
@@ -83,7 +83,7 @@ class TestGetActiveAccount:
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_none_for_nonexistent_email(self, mock_db):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
result = _get_active_account("missing@example.com")
|
||||
|
||||
@@ -205,7 +205,7 @@ class TestEnterpriseAppDSLExport:
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_success_returns_200(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
|
||||
mock_app = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
|
||||
mock_db.session.get.return_value = mock_app
|
||||
mock_dsl_cls.export_dsl.return_value = "version: 0.6.0\nkind: app\n"
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
@@ -221,7 +221,7 @@ class TestEnterpriseAppDSLExport:
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_with_secret(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
|
||||
mock_app = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
|
||||
mock_db.session.get.return_value = mock_app
|
||||
mock_dsl_cls.export_dsl.return_value = "yaml-data"
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
@@ -234,7 +234,7 @@ class TestEnterpriseAppDSLExport:
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_app_not_found_returns_404(self, mock_db, api_instance, app: Flask):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.get.return_value = None
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=false"):
|
||||
|
||||
@@ -621,7 +621,7 @@ class TestConvertDatasetRetrieverTool:
|
||||
class TestBaseAgentRunnerInit:
|
||||
def test_init_sets_stream_tool_call_and_files(self, mocker):
|
||||
session = mocker.MagicMock()
|
||||
session.query.return_value.where.return_value.count.return_value = 2
|
||||
session.scalar.return_value = 2
|
||||
mocker.patch.object(module.db, "session", session)
|
||||
|
||||
mocker.patch.object(BaseAgentRunner, "organize_agent_history", return_value=[])
|
||||
|
||||
@@ -114,13 +114,9 @@ class TestOnToolEnd:
|
||||
document = mocker.Mock()
|
||||
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
|
||||
|
||||
mock_query = mocker.Mock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
|
||||
handler.on_tool_end([document])
|
||||
|
||||
mock_query.update.assert_called_once()
|
||||
mock_db.session.execute.assert_called_once()
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
def test_on_tool_end_non_parent_child_index(self, handler, mocker):
|
||||
@@ -138,13 +134,9 @@ class TestOnToolEnd:
|
||||
"dataset_id": "dataset-1",
|
||||
}
|
||||
|
||||
mock_query = mocker.Mock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
|
||||
handler.on_tool_end([document])
|
||||
|
||||
mock_query.update.assert_called_once()
|
||||
mock_db.session.execute.assert_called_once()
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
def test_on_tool_end_empty_documents(self, handler):
|
||||
|
||||
@@ -38,13 +38,13 @@ class TestObfuscatedToken:
|
||||
|
||||
|
||||
class TestEncryptToken:
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
def test_successful_encryption(self, mock_encrypt, mock_query):
|
||||
"""Test successful token encryption"""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.encrypt_public_key = "mock_public_key"
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
mock_encrypt.return_value = b"encrypted_data"
|
||||
|
||||
result = encrypt_token("tenant-123", "test_token")
|
||||
@@ -52,10 +52,10 @@ class TestEncryptToken:
|
||||
assert result == base64.b64encode(b"encrypted_data").decode()
|
||||
mock_encrypt.assert_called_with("test_token", "mock_public_key")
|
||||
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
def test_tenant_not_found(self, mock_query):
|
||||
"""Test error when tenant doesn't exist"""
|
||||
mock_query.return_value.where.return_value.first.return_value = None
|
||||
mock_query.return_value = None
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
encrypt_token("invalid-tenant", "test_token")
|
||||
@@ -119,7 +119,7 @@ class TestGetDecryptDecoding:
|
||||
|
||||
|
||||
class TestEncryptDecryptIntegration:
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
@patch("libs.rsa.decrypt")
|
||||
def test_should_encrypt_and_decrypt_consistently(self, mock_decrypt, mock_encrypt, mock_query):
|
||||
@@ -127,7 +127,7 @@ class TestEncryptDecryptIntegration:
|
||||
# Setup mock tenant
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.encrypt_public_key = "mock_public_key"
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
|
||||
# Setup mock encryption/decryption
|
||||
original_token = "test_token_123"
|
||||
@@ -146,14 +146,14 @@ class TestEncryptDecryptIntegration:
|
||||
class TestSecurity:
|
||||
"""Critical security tests for encryption system"""
|
||||
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
def test_cross_tenant_isolation(self, mock_encrypt, mock_query):
|
||||
"""Ensure tokens encrypted for one tenant cannot be used by another"""
|
||||
# Setup mock tenant
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.encrypt_public_key = "tenant1_public_key"
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
mock_encrypt.return_value = b"encrypted_for_tenant1"
|
||||
|
||||
# Encrypt token for tenant1
|
||||
@@ -181,12 +181,12 @@ class TestSecurity:
|
||||
with pytest.raises(Exception, match="Decryption error"):
|
||||
decrypt_token("tenant-123", tampered)
|
||||
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
def test_encryption_randomness(self, mock_encrypt, mock_query):
|
||||
"""Ensure same plaintext produces different ciphertext"""
|
||||
mock_tenant = MagicMock(encrypt_public_key="key")
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
|
||||
# Different outputs for same input
|
||||
mock_encrypt.side_effect = [b"enc1", b"enc2", b"enc3"]
|
||||
@@ -205,13 +205,13 @@ class TestEdgeCases:
|
||||
# Test empty string (which is a valid str type)
|
||||
assert obfuscated_token("") == ""
|
||||
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
def test_should_handle_empty_token_encryption(self, mock_encrypt, mock_query):
|
||||
"""Test encryption of empty token"""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.encrypt_public_key = "mock_public_key"
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
mock_encrypt.return_value = b"encrypted_empty"
|
||||
|
||||
result = encrypt_token("tenant-123", "")
|
||||
@@ -219,13 +219,13 @@ class TestEdgeCases:
|
||||
assert result == base64.b64encode(b"encrypted_empty").decode()
|
||||
mock_encrypt.assert_called_with("", "mock_public_key")
|
||||
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
def test_should_handle_special_characters_in_token(self, mock_encrypt, mock_query):
|
||||
"""Test tokens containing special/unicode characters"""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.encrypt_public_key = "mock_public_key"
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
mock_encrypt.return_value = b"encrypted_special"
|
||||
|
||||
# Test various special characters
|
||||
@@ -242,13 +242,13 @@ class TestEdgeCases:
|
||||
assert result == base64.b64encode(b"encrypted_special").decode()
|
||||
mock_encrypt.assert_called_with(token, "mock_public_key")
|
||||
|
||||
@patch("models.engine.db.session.query")
|
||||
@patch("extensions.ext_database.db.session.get")
|
||||
@patch("libs.rsa.encrypt")
|
||||
def test_should_handle_rsa_size_limits(self, mock_encrypt, mock_query):
|
||||
"""Test behavior when token exceeds RSA encryption limits"""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.encrypt_public_key = "mock_public_key"
|
||||
mock_query.return_value.where.return_value.first.return_value = mock_tenant
|
||||
mock_query.return_value = mock_tenant
|
||||
|
||||
# RSA 2048-bit can only encrypt ~245 bytes
|
||||
# The actual limit depends on padding scheme
|
||||
|
||||
@@ -314,8 +314,8 @@ class TestLLMGenerator:
|
||||
assert "An unexpected error occurred" in result["error"]
|
||||
|
||||
def test_instruction_modify_legacy_no_last_run(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
|
||||
# Mock __instruction_modify_common call via invoke_llm
|
||||
mock_response = MagicMock()
|
||||
@@ -328,12 +328,12 @@ class TestLLMGenerator:
|
||||
assert result == {"modified": "prompt"}
|
||||
|
||||
def test_instruction_modify_legacy_with_last_run(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
last_run = MagicMock()
|
||||
last_run.query = "q"
|
||||
last_run.answer = "a"
|
||||
last_run.error = "e"
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = last_run
|
||||
mock_scalar.return_value = last_run
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = '{"modified": "prompt"}'
|
||||
@@ -483,8 +483,8 @@ class TestLLMGenerator:
|
||||
|
||||
def test_instruction_modify_common_placeholders(self, mock_model_instance, model_config_entity):
|
||||
# Testing placeholders replacement via instruction_modify_legacy for convenience
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = '{"ok": true}'
|
||||
@@ -504,8 +504,8 @@ class TestLLMGenerator:
|
||||
assert "current_val" in user_msg_dict["instruction"]
|
||||
|
||||
def test_instruction_modify_common_no_braces(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = "No braces here"
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
@@ -516,8 +516,8 @@ class TestLLMGenerator:
|
||||
assert "Could not find a valid JSON object" in result["error"]
|
||||
|
||||
def test_instruction_modify_common_not_dict(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = "[1, 2, 3]"
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
@@ -556,8 +556,8 @@ class TestLLMGenerator:
|
||||
)
|
||||
|
||||
def test_instruction_modify_common_invoke_error(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
mock_model_instance.invoke_llm.side_effect = InvokeError("Invoke Failed")
|
||||
|
||||
result = LLMGenerator.instruction_modify_legacy(
|
||||
@@ -566,8 +566,8 @@ class TestLLMGenerator:
|
||||
assert "Failed to generate code" in result["error"]
|
||||
|
||||
def test_instruction_modify_common_exception(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
mock_model_instance.invoke_llm.side_effect = Exception("Random error")
|
||||
|
||||
result = LLMGenerator.instruction_modify_legacy(
|
||||
@@ -576,8 +576,8 @@ class TestLLMGenerator:
|
||||
assert "An unexpected error occurred" in result["error"]
|
||||
|
||||
def test_instruction_modify_common_json_error(self, mock_model_instance, model_config_entity):
|
||||
with patch("extensions.ext_database.db.session.query") as mock_query:
|
||||
mock_query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
|
||||
mock_scalar.return_value = None
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.get_text_content.return_value = "No JSON here"
|
||||
|
||||
@@ -332,27 +332,21 @@ class TestPluginAppBackwardsInvocation:
|
||||
PluginAppBackwardsInvocation._get_user("uid")
|
||||
|
||||
def test_get_app_returns_app(self, mocker):
|
||||
query_chain = MagicMock()
|
||||
query_chain.where.return_value = query_chain
|
||||
app_obj = MagicMock(id="app")
|
||||
query_chain.first.return_value = app_obj
|
||||
db = SimpleNamespace(session=MagicMock(query=MagicMock(return_value=query_chain)))
|
||||
db = SimpleNamespace(session=MagicMock(scalar=MagicMock(return_value=app_obj)))
|
||||
mocker.patch("core.plugin.backwards_invocation.app.db", db)
|
||||
|
||||
assert PluginAppBackwardsInvocation._get_app("app", "tenant") is app_obj
|
||||
|
||||
def test_get_app_raises_when_missing(self, mocker):
|
||||
query_chain = MagicMock()
|
||||
query_chain.where.return_value = query_chain
|
||||
query_chain.first.return_value = None
|
||||
db = SimpleNamespace(session=MagicMock(query=MagicMock(return_value=query_chain)))
|
||||
db = SimpleNamespace(session=MagicMock(scalar=MagicMock(return_value=None)))
|
||||
mocker.patch("core.plugin.backwards_invocation.app.db", db)
|
||||
|
||||
with pytest.raises(ValueError, match="app not found"):
|
||||
PluginAppBackwardsInvocation._get_app("app", "tenant")
|
||||
|
||||
def test_get_app_raises_when_query_fails(self, mocker):
|
||||
db = SimpleNamespace(session=MagicMock(query=MagicMock(side_effect=RuntimeError("db down"))))
|
||||
db = SimpleNamespace(session=MagicMock(scalar=MagicMock(side_effect=RuntimeError("db down"))))
|
||||
mocker.patch("core.plugin.backwards_invocation.app.db", db)
|
||||
|
||||
with pytest.raises(ValueError, match="app not found"):
|
||||
|
||||
@@ -38,11 +38,9 @@ def test_tool_label_manager_filter_tool_labels():
|
||||
def test_tool_label_manager_update_tool_labels_db():
|
||||
controller = _api_controller("api-1")
|
||||
with patch("core.tools.tool_label_manager.db") as mock_db:
|
||||
delete_query = mock_db.session.query.return_value.where.return_value
|
||||
delete_query.delete.return_value = None
|
||||
ToolLabelManager.update_tool_labels(controller, ["search", "search", "invalid"])
|
||||
|
||||
delete_query.delete.assert_called_once()
|
||||
mock_db.session.execute.assert_called_once()
|
||||
# only one valid unique label should be inserted.
|
||||
assert mock_db.session.add.call_count == 1
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@@ -220,9 +220,7 @@ def test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks():
|
||||
with patch.object(ToolManager, "get_builtin_provider", return_value=controller):
|
||||
with patch("core.helper.credential_utils.check_credential_policy_compliance"):
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
mock_db.session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
builtin_provider
|
||||
)
|
||||
mock_db.session.scalar.return_value = builtin_provider
|
||||
encrypter = Mock()
|
||||
encrypter.decrypt.return_value = {"api_key": "secret"}
|
||||
cache = Mock()
|
||||
@@ -274,7 +272,7 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials(
|
||||
)
|
||||
refreshed = SimpleNamespace(credentials={"token": "new"}, expires_at=123456)
|
||||
|
||||
mock_db.session.query.return_value.where.return_value.order_by.return_value.first.return_value = builtin_provider
|
||||
mock_db.session.scalar.return_value = builtin_provider
|
||||
encrypter = Mock()
|
||||
encrypter.decrypt.return_value = {"token": "old"}
|
||||
encrypter.encrypt.return_value = {"token": "encrypted"}
|
||||
@@ -698,12 +696,10 @@ def test_get_api_provider_controller_returns_controller_and_credentials():
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="disclaimer",
|
||||
)
|
||||
db_query = Mock()
|
||||
db_query.where.return_value.first.return_value = provider
|
||||
controller = Mock()
|
||||
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
mock_db.session.query.return_value = db_query
|
||||
mock_db.session.scalar.return_value = provider
|
||||
with patch(
|
||||
"core.tools.tool_manager.ApiToolProviderController.from_db", return_value=controller
|
||||
) as mock_from_db:
|
||||
@@ -730,12 +726,10 @@ def test_user_get_api_provider_masks_credentials_and_adds_labels():
|
||||
privacy_policy="privacy",
|
||||
custom_disclaimer="disclaimer",
|
||||
)
|
||||
db_query = Mock()
|
||||
db_query.where.return_value.first.return_value = provider
|
||||
controller = Mock()
|
||||
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
mock_db.session.query.return_value = db_query
|
||||
mock_db.session.scalar.return_value = provider
|
||||
with patch("core.tools.tool_manager.ApiToolProviderController.from_db", return_value=controller):
|
||||
encrypter = Mock()
|
||||
encrypter.decrypt.return_value = {"api_key_value": "secret"}
|
||||
@@ -750,7 +744,7 @@ def test_user_get_api_provider_masks_credentials_and_adds_labels():
|
||||
|
||||
def test_get_api_provider_controller_not_found_raises():
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
with pytest.raises(ToolProviderNotFoundError, match="api provider missing not found"):
|
||||
ToolManager.get_api_provider_controller("tenant-1", "missing")
|
||||
|
||||
@@ -809,14 +803,14 @@ def test_generate_tool_icon_urls_for_workflow_and_api():
|
||||
workflow_provider = SimpleNamespace(icon='{"background": "#222", "content": "W"}')
|
||||
api_provider = SimpleNamespace(icon='{"background": "#333", "content": "A"}')
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [workflow_provider, api_provider]
|
||||
mock_db.session.scalar.side_effect = [workflow_provider, api_provider]
|
||||
assert ToolManager.generate_workflow_tool_icon_url("tenant-1", "wf-1") == {"background": "#222", "content": "W"}
|
||||
assert ToolManager.generate_api_tool_icon_url("tenant-1", "api-1") == {"background": "#333", "content": "A"}
|
||||
|
||||
|
||||
def test_generate_tool_icon_urls_missing_workflow_and_api_use_default():
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
assert ToolManager.generate_workflow_tool_icon_url("tenant-1", "missing")["background"] == "#252525"
|
||||
assert ToolManager.generate_api_tool_icon_url("tenant-1", "missing")["background"] == "#252525"
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ def test_single_dataset_retriever_non_economy_run_sorts_context_and_resources():
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.scalar.side_effect = [dataset, lookup_doc_low, lookup_doc_high]
|
||||
db_session.query.return_value.filter_by.return_value.first.return_value = dataset
|
||||
db_session.get.return_value = dataset
|
||||
|
||||
tool = SingleDatasetRetrieverTool(
|
||||
tenant_id="tenant-1",
|
||||
@@ -444,7 +444,7 @@ def test_multi_dataset_retriever_run_orders_segments_and_returns_resources():
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.scalars.return_value.all.return_value = [segment_for_node_2, segment_for_node_1]
|
||||
db_session.query.return_value.filter_by.return_value.first.side_effect = [
|
||||
db_session.get.side_effect = [
|
||||
SimpleNamespace(id="dataset-2", name="Dataset Two"),
|
||||
SimpleNamespace(id="dataset-1", name="Dataset One"),
|
||||
]
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.rag.index_processor.constant.built_in_field import BuiltInField, MetadataDataSource
|
||||
from models.dataset import Dataset
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
DocumentMetadataOperation,
|
||||
MetadataArgs,
|
||||
MetadataDetail,
|
||||
MetadataOperationData,
|
||||
)
|
||||
from services.metadata_service import MetadataService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DocumentStub:
|
||||
id: str
|
||||
name: str
|
||||
uploader: str
|
||||
upload_date: datetime
|
||||
last_update_date: datetime
|
||||
data_source_type: str
|
||||
doc_metadata: dict[str, object] | None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(mocker: MockerFixture) -> MagicMock:
|
||||
mocked_db = mocker.patch("services.metadata_service.db")
|
||||
mocked_db.session = MagicMock()
|
||||
return mocked_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(mocker: MockerFixture) -> MagicMock:
|
||||
return mocker.patch("services.metadata_service.redis_client")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_account(mocker: MockerFixture) -> MagicMock:
|
||||
mock_user = SimpleNamespace(id="user-1")
|
||||
return mocker.patch("services.metadata_service.current_account_with_tenant", return_value=(mock_user, "tenant-1"))
|
||||
|
||||
|
||||
def _build_document(document_id: str, doc_metadata: dict[str, object] | None = None) -> _DocumentStub:
|
||||
now = datetime(2025, 1, 1, 10, 30, tzinfo=UTC)
|
||||
return _DocumentStub(
|
||||
id=document_id,
|
||||
name=f"doc-{document_id}",
|
||||
uploader="qa@example.com",
|
||||
upload_date=now,
|
||||
last_update_date=now,
|
||||
data_source_type="upload_file",
|
||||
doc_metadata=doc_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _dataset(**kwargs: Any) -> Dataset:
|
||||
return cast(Dataset, SimpleNamespace(**kwargs))
|
||||
|
||||
|
||||
def test_create_metadata_should_raise_value_error_when_name_exceeds_limit() -> None:
|
||||
# Arrange
|
||||
metadata_args = MetadataArgs(type="string", name="x" * 256)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="cannot exceed 255"):
|
||||
MetadataService.create_metadata("dataset-1", metadata_args)
|
||||
|
||||
|
||||
def test_create_metadata_should_raise_value_error_when_metadata_name_already_exists(
|
||||
mock_db: MagicMock,
|
||||
mock_current_account: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
metadata_args = MetadataArgs(type="string", name="priority")
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = object()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
MetadataService.create_metadata("dataset-1", metadata_args)
|
||||
|
||||
# Assert
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_create_metadata_should_raise_value_error_when_name_collides_with_builtin(
|
||||
mock_db: MagicMock, mock_current_account: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
metadata_args = MetadataArgs(type="string", name=BuiltInField.document_name)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Built-in fields"):
|
||||
MetadataService.create_metadata("dataset-1", metadata_args)
|
||||
|
||||
|
||||
def test_create_metadata_should_persist_metadata_when_input_is_valid(
|
||||
mock_db: MagicMock, mock_current_account: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
metadata_args = MetadataArgs(type="number", name="score")
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = MetadataService.create_metadata("dataset-1", metadata_args)
|
||||
|
||||
# Assert
|
||||
assert result.tenant_id == "tenant-1"
|
||||
assert result.dataset_id == "dataset-1"
|
||||
assert result.type == "number"
|
||||
assert result.name == "score"
|
||||
assert result.created_by == "user-1"
|
||||
mock_db.session.add.assert_called_once_with(result)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_update_metadata_name_should_raise_value_error_when_name_exceeds_limit() -> None:
|
||||
# Arrange
|
||||
too_long_name = "x" * 256
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="cannot exceed 255"):
|
||||
MetadataService.update_metadata_name("dataset-1", "metadata-1", too_long_name)
|
||||
|
||||
|
||||
def test_update_metadata_name_should_raise_value_error_when_duplicate_name_exists(
|
||||
mock_db: MagicMock, mock_current_account: MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = object()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
MetadataService.update_metadata_name("dataset-1", "metadata-1", "duplicate")
|
||||
|
||||
# Assert
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_update_metadata_name_should_raise_value_error_when_name_collides_with_builtin(
|
||||
mock_db: MagicMock,
|
||||
mock_current_account: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Built-in fields"):
|
||||
MetadataService.update_metadata_name("dataset-1", "metadata-1", BuiltInField.source)
|
||||
|
||||
# Assert
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_update_metadata_name_should_update_bound_documents_and_return_metadata(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mock_current_account: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
fixed_now = datetime(2025, 2, 1, 0, 0, tzinfo=UTC)
|
||||
mocker.patch("services.metadata_service.naive_utc_now", return_value=fixed_now)
|
||||
|
||||
metadata = SimpleNamespace(id="metadata-1", name="old_name", updated_by=None, updated_at=None)
|
||||
bindings = [SimpleNamespace(document_id="doc-1"), SimpleNamespace(document_id="doc-2")]
|
||||
query_duplicate = MagicMock()
|
||||
query_duplicate.filter_by.return_value.first.return_value = None
|
||||
query_metadata = MagicMock()
|
||||
query_metadata.filter_by.return_value.first.return_value = metadata
|
||||
query_bindings = MagicMock()
|
||||
query_bindings.filter_by.return_value.all.return_value = bindings
|
||||
mock_db.session.query.side_effect = [query_duplicate, query_metadata, query_bindings]
|
||||
|
||||
doc_1 = _build_document("1", {"old_name": "value", "other": "keep"})
|
||||
doc_2 = _build_document("2", None)
|
||||
mock_get_documents = mocker.patch("services.metadata_service.DocumentService.get_document_by_ids")
|
||||
mock_get_documents.return_value = [doc_1, doc_2]
|
||||
|
||||
# Act
|
||||
result = MetadataService.update_metadata_name("dataset-1", "metadata-1", "new_name")
|
||||
|
||||
# Assert
|
||||
assert result is metadata
|
||||
assert metadata.name == "new_name"
|
||||
assert metadata.updated_by == "user-1"
|
||||
assert metadata.updated_at == fixed_now
|
||||
assert doc_1.doc_metadata == {"other": "keep", "new_name": "value"}
|
||||
assert doc_2.doc_metadata == {"new_name": None}
|
||||
mock_get_documents.assert_called_once_with(["doc-1", "doc-2"])
|
||||
mock_db.session.commit.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("dataset_metadata_lock_dataset-1")
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_update_metadata_name_should_return_none_when_metadata_does_not_exist(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mock_current_account: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
mock_logger = mocker.patch("services.metadata_service.logger")
|
||||
|
||||
query_duplicate = MagicMock()
|
||||
query_duplicate.filter_by.return_value.first.return_value = None
|
||||
query_metadata = MagicMock()
|
||||
query_metadata.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.query.side_effect = [query_duplicate, query_metadata]
|
||||
|
||||
# Act
|
||||
result = MetadataService.update_metadata_name("dataset-1", "missing-id", "new_name")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_logger.exception.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("dataset_metadata_lock_dataset-1")
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_metadata_should_remove_metadata_and_related_document_fields(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
metadata = SimpleNamespace(id="metadata-1", name="obsolete")
|
||||
bindings = [SimpleNamespace(document_id="doc-1")]
|
||||
query_metadata = MagicMock()
|
||||
query_metadata.filter_by.return_value.first.return_value = metadata
|
||||
query_bindings = MagicMock()
|
||||
query_bindings.filter_by.return_value.all.return_value = bindings
|
||||
mock_db.session.query.side_effect = [query_metadata, query_bindings]
|
||||
|
||||
document = _build_document("1", {"obsolete": "legacy", "remaining": "value"})
|
||||
mocker.patch("services.metadata_service.DocumentService.get_document_by_ids", return_value=[document])
|
||||
|
||||
# Act
|
||||
result = MetadataService.delete_metadata("dataset-1", "metadata-1")
|
||||
|
||||
# Assert
|
||||
assert result is metadata
|
||||
assert document.doc_metadata == {"remaining": "value"}
|
||||
mock_db.session.delete.assert_called_once_with(metadata)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("dataset_metadata_lock_dataset-1")
|
||||
|
||||
|
||||
def test_delete_metadata_should_return_none_when_metadata_is_missing(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_logger = mocker.patch("services.metadata_service.logger")
|
||||
|
||||
# Act
|
||||
result = MetadataService.delete_metadata("dataset-1", "missing-id")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_logger.exception.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("dataset_metadata_lock_dataset-1")
|
||||
|
||||
|
||||
def test_get_built_in_fields_should_return_all_expected_fields() -> None:
|
||||
# Arrange
|
||||
expected_names = {
|
||||
BuiltInField.document_name,
|
||||
BuiltInField.uploader,
|
||||
BuiltInField.upload_date,
|
||||
BuiltInField.last_update_date,
|
||||
BuiltInField.source,
|
||||
}
|
||||
|
||||
# Act
|
||||
result = MetadataService.get_built_in_fields()
|
||||
|
||||
# Assert
|
||||
assert {item["name"] for item in result} == expected_names
|
||||
assert [item["type"] for item in result] == ["string", "string", "time", "time", "string"]
|
||||
|
||||
|
||||
def test_enable_built_in_field_should_return_immediately_when_already_enabled(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=True)
|
||||
get_docs = mocker.patch("services.metadata_service.DocumentService.get_working_documents_by_dataset_id")
|
||||
|
||||
# Act
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
|
||||
# Assert
|
||||
get_docs.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_enable_built_in_field_should_populate_documents_and_enable_flag(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=False)
|
||||
doc_1 = _build_document("1", {"custom": "value"})
|
||||
doc_2 = _build_document("2", None)
|
||||
mocker.patch(
|
||||
"services.metadata_service.DocumentService.get_working_documents_by_dataset_id",
|
||||
return_value=[doc_1, doc_2],
|
||||
)
|
||||
|
||||
# Act
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
|
||||
# Assert
|
||||
assert dataset.built_in_field_enabled is True
|
||||
assert doc_1.doc_metadata is not None
|
||||
assert doc_1.doc_metadata[BuiltInField.document_name] == "doc-1"
|
||||
assert doc_1.doc_metadata[BuiltInField.source] == MetadataDataSource.upload_file
|
||||
assert doc_2.doc_metadata is not None
|
||||
assert doc_2.doc_metadata[BuiltInField.uploader] == "qa@example.com"
|
||||
mock_db.session.commit.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("dataset_metadata_lock_dataset-1")
|
||||
|
||||
|
||||
def test_disable_built_in_field_should_return_immediately_when_already_disabled(
|
||||
mock_db: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=False)
|
||||
get_docs = mocker.patch("services.metadata_service.DocumentService.get_working_documents_by_dataset_id")
|
||||
|
||||
# Act
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
|
||||
# Assert
|
||||
get_docs.assert_not_called()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_disable_built_in_field_should_remove_builtin_keys_and_disable_flag(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=True)
|
||||
document = _build_document(
|
||||
"1",
|
||||
{
|
||||
BuiltInField.document_name: "doc",
|
||||
BuiltInField.uploader: "user",
|
||||
BuiltInField.upload_date: 1.0,
|
||||
BuiltInField.last_update_date: 2.0,
|
||||
BuiltInField.source: MetadataDataSource.upload_file,
|
||||
"custom": "keep",
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"services.metadata_service.DocumentService.get_working_documents_by_dataset_id",
|
||||
return_value=[document],
|
||||
)
|
||||
|
||||
# Act
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
|
||||
# Assert
|
||||
assert dataset.built_in_field_enabled is False
|
||||
assert document.doc_metadata == {"custom": "keep"}
|
||||
mock_db.session.commit.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("dataset_metadata_lock_dataset-1")
|
||||
|
||||
|
||||
def test_update_documents_metadata_should_replace_metadata_and_create_bindings_on_full_update(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mock_current_account: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=False)
|
||||
document = _build_document("1", {"legacy": "value"})
|
||||
mocker.patch("services.metadata_service.DocumentService.get_document", return_value=document)
|
||||
delete_chain = mock_db.session.query.return_value.filter_by.return_value
|
||||
delete_chain.delete.return_value = 1
|
||||
operation = DocumentMetadataOperation(
|
||||
document_id="1",
|
||||
metadata_list=[MetadataDetail(id="meta-1", name="priority", value="high")],
|
||||
partial_update=False,
|
||||
)
|
||||
metadata_args = MetadataOperationData(operation_data=[operation])
|
||||
|
||||
# Act
|
||||
MetadataService.update_documents_metadata(dataset, metadata_args)
|
||||
|
||||
# Assert
|
||||
assert document.doc_metadata == {"priority": "high"}
|
||||
delete_chain.delete.assert_called_once()
|
||||
assert mock_db.session.commit.call_count == 1
|
||||
mock_redis_client.delete.assert_called_once_with("document_metadata_lock_1")
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_update_documents_metadata_should_skip_existing_binding_and_preserve_existing_fields_on_partial_update(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mock_current_account: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=True)
|
||||
document = _build_document("1", {"existing": "value"})
|
||||
mocker.patch("services.metadata_service.DocumentService.get_document", return_value=document)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = object()
|
||||
operation = DocumentMetadataOperation(
|
||||
document_id="1",
|
||||
metadata_list=[MetadataDetail(id="meta-1", name="new_key", value="new_value")],
|
||||
partial_update=True,
|
||||
)
|
||||
metadata_args = MetadataOperationData(operation_data=[operation])
|
||||
|
||||
# Act
|
||||
MetadataService.update_documents_metadata(dataset, metadata_args)
|
||||
|
||||
# Assert
|
||||
assert document.doc_metadata is not None
|
||||
assert document.doc_metadata["existing"] == "value"
|
||||
assert document.doc_metadata["new_key"] == "new_value"
|
||||
assert document.doc_metadata[BuiltInField.source] == MetadataDataSource.upload_file
|
||||
assert mock_db.session.commit.call_count == 1
|
||||
assert mock_db.session.add.call_count == 1
|
||||
mock_redis_client.delete.assert_called_once_with("document_metadata_lock_1")
|
||||
mock_current_account.assert_called_once()
|
||||
|
||||
|
||||
def test_update_documents_metadata_should_raise_and_rollback_when_document_not_found(
|
||||
mock_db: MagicMock,
|
||||
mock_redis_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=False)
|
||||
mocker.patch("services.metadata_service.DocumentService.get_document", return_value=None)
|
||||
operation = DocumentMetadataOperation(document_id="404", metadata_list=[], partial_update=True)
|
||||
metadata_args = MetadataOperationData(operation_data=[operation])
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="Document not found"):
|
||||
MetadataService.update_documents_metadata(dataset, metadata_args)
|
||||
|
||||
# Assert
|
||||
mock_db.session.rollback.assert_called_once()
|
||||
mock_redis_client.delete.assert_called_once_with("document_metadata_lock_404")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dataset_id", "document_id", "expected_key"),
|
||||
[
|
||||
("dataset-1", None, "dataset_metadata_lock_dataset-1"),
|
||||
(None, "doc-1", "document_metadata_lock_doc-1"),
|
||||
],
|
||||
)
|
||||
def test_knowledge_base_metadata_lock_check_should_set_lock_when_not_already_locked(
|
||||
dataset_id: str | None,
|
||||
document_id: str | None,
|
||||
expected_key: str,
|
||||
mock_redis_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = None
|
||||
|
||||
# Act
|
||||
MetadataService.knowledge_base_metadata_lock_check(dataset_id, document_id)
|
||||
|
||||
# Assert
|
||||
mock_redis_client.set.assert_called_once_with(expected_key, 1, ex=3600)
|
||||
|
||||
|
||||
def test_knowledge_base_metadata_lock_check_should_raise_when_dataset_lock_exists(
|
||||
mock_redis_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = 1
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="knowledge base metadata operation is running"):
|
||||
MetadataService.knowledge_base_metadata_lock_check("dataset-1", None)
|
||||
|
||||
|
||||
def test_knowledge_base_metadata_lock_check_should_raise_when_document_lock_exists(
|
||||
mock_redis_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_redis_client.get.return_value = 1
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(ValueError, match="document metadata operation is running"):
|
||||
MetadataService.knowledge_base_metadata_lock_check(None, "doc-1")
|
||||
|
||||
|
||||
def test_get_dataset_metadatas_should_exclude_builtin_and_include_binding_counts(mock_db: MagicMock) -> None:
|
||||
# Arrange
|
||||
dataset = _dataset(
|
||||
id="dataset-1",
|
||||
built_in_field_enabled=True,
|
||||
doc_metadata=[
|
||||
{"id": "meta-1", "name": "priority", "type": "string"},
|
||||
{"id": "built-in", "name": "ignored", "type": "string"},
|
||||
{"id": "meta-2", "name": "score", "type": "number"},
|
||||
],
|
||||
)
|
||||
count_chain = mock_db.session.query.return_value.filter_by.return_value
|
||||
count_chain.count.side_effect = [3, 1]
|
||||
|
||||
# Act
|
||||
result = MetadataService.get_dataset_metadatas(dataset)
|
||||
|
||||
# Assert
|
||||
assert result["built_in_field_enabled"] is True
|
||||
assert result["doc_metadata"] == [
|
||||
{"id": "meta-1", "name": "priority", "type": "string", "count": 3},
|
||||
{"id": "meta-2", "name": "score", "type": "number", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_get_dataset_metadatas_should_return_empty_list_when_no_metadata(mock_db: MagicMock) -> None:
|
||||
# Arrange
|
||||
dataset = _dataset(id="dataset-1", built_in_field_enabled=False, doc_metadata=None)
|
||||
|
||||
# Act
|
||||
result = MetadataService.get_dataset_metadatas(dataset)
|
||||
|
||||
# Assert
|
||||
assert result == {"doc_metadata": [], "built_in_field_enabled": False}
|
||||
mock_db.session.query.assert_not_called()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,576 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models.account import Tenant
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants used throughout the tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TENANT_ID = "tenant-abc"
|
||||
ACCOUNT_ID = "account-xyz"
|
||||
FILES_BASE_URL = "https://files.example.com"
|
||||
|
||||
DB_PATH = "services.workspace_service.db"
|
||||
FEATURE_SERVICE_PATH = "services.workspace_service.FeatureService.get_features"
|
||||
TENANT_SERVICE_PATH = "services.workspace_service.TenantService.has_roles"
|
||||
DIFY_CONFIG_PATH = "services.workspace_service.dify_config"
|
||||
CURRENT_USER_PATH = "services.workspace_service.current_user"
|
||||
CREDIT_POOL_SERVICE_PATH = "services.credit_pool_service.CreditPoolService.get_pool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tenant(
|
||||
tenant_id: str = TENANT_ID,
|
||||
name: str = "My Workspace",
|
||||
plan: str = "sandbox",
|
||||
status: str = "active",
|
||||
custom_config: dict | None = None,
|
||||
) -> Tenant:
|
||||
"""Create a minimal Tenant-like namespace."""
|
||||
return cast(
|
||||
Tenant,
|
||||
SimpleNamespace(
|
||||
id=tenant_id,
|
||||
name=name,
|
||||
plan=plan,
|
||||
status=status,
|
||||
created_at="2024-01-01T00:00:00Z",
|
||||
custom_config_dict=custom_config or {},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_feature(
|
||||
can_replace_logo: bool = False,
|
||||
next_credit_reset_date: str | None = None,
|
||||
billing_plan: str = "sandbox",
|
||||
) -> MagicMock:
|
||||
"""Create a feature namespace matching what FeatureService.get_features returns."""
|
||||
feature = MagicMock()
|
||||
feature.can_replace_logo = can_replace_logo
|
||||
feature.next_credit_reset_date = next_credit_reset_date
|
||||
feature.billing.subscription.plan = billing_plan
|
||||
return feature
|
||||
|
||||
|
||||
def _make_pool(quota_limit: int, quota_used: int) -> MagicMock:
|
||||
pool = MagicMock()
|
||||
pool.quota_limit = quota_limit
|
||||
pool.quota_used = quota_used
|
||||
return pool
|
||||
|
||||
|
||||
def _make_tenant_account_join(role: str = "normal") -> SimpleNamespace:
|
||||
return SimpleNamespace(role=role)
|
||||
|
||||
|
||||
def _tenant_info(result: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any] | None, result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_user() -> SimpleNamespace:
|
||||
"""Return a lightweight current_user stand-in."""
|
||||
return SimpleNamespace(id=ACCOUNT_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_mocks(mocker: MockerFixture, mock_current_user: SimpleNamespace) -> dict:
|
||||
"""
|
||||
Patch the common external boundaries used by WorkspaceService.get_tenant_info.
|
||||
|
||||
Returns a dict of named mocks so individual tests can customise them.
|
||||
"""
|
||||
mocker.patch(CURRENT_USER_PATH, mock_current_user)
|
||||
|
||||
mock_db_session = mocker.patch(f"{DB_PATH}.session")
|
||||
mock_query_chain = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query_chain
|
||||
mock_query_chain.where.return_value = mock_query_chain
|
||||
mock_query_chain.first.return_value = _make_tenant_account_join(role="owner")
|
||||
|
||||
mock_feature = mocker.patch(FEATURE_SERVICE_PATH, return_value=_make_feature())
|
||||
mock_has_roles = mocker.patch(TENANT_SERVICE_PATH, return_value=False)
|
||||
mock_config = mocker.patch(DIFY_CONFIG_PATH)
|
||||
mock_config.EDITION = "SELF_HOSTED"
|
||||
mock_config.FILES_URL = FILES_BASE_URL
|
||||
|
||||
return {
|
||||
"db_session": mock_db_session,
|
||||
"query_chain": mock_query_chain,
|
||||
"get_features": mock_feature,
|
||||
"has_roles": mock_has_roles,
|
||||
"config": mock_config,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. None Tenant Handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tenant_info_should_return_none_when_tenant_is_none() -> None:
|
||||
"""get_tenant_info should short-circuit and return None for a falsy tenant."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
tenant = None
|
||||
|
||||
# Act
|
||||
result = WorkspaceService.get_tenant_info(cast(Tenant, tenant))
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_tenant_info_should_return_none_when_tenant_is_falsy() -> None:
|
||||
"""get_tenant_info treats any falsy value as absent (e.g. empty string, 0)."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange / Act / Assert
|
||||
assert WorkspaceService.get_tenant_info("") is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Basic Tenant Info — happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tenant_info_should_return_base_fields(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""get_tenant_info should always return the six base scalar fields."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["id"] == TENANT_ID
|
||||
assert result["name"] == "My Workspace"
|
||||
assert result["plan"] == "sandbox"
|
||||
assert result["status"] == "active"
|
||||
assert result["created_at"] == "2024-01-01T00:00:00Z"
|
||||
assert result["trial_end_reason"] is None
|
||||
|
||||
|
||||
def test_get_tenant_info_should_populate_role_from_tenant_account_join(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""The 'role' field should be taken from TenantAccountJoin, not the default."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
basic_mocks["query_chain"].first.return_value = _make_tenant_account_join(role="admin")
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["role"] == "admin"
|
||||
|
||||
|
||||
def test_get_tenant_info_should_raise_assertion_when_tenant_account_join_missing(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""
|
||||
The service asserts that TenantAccountJoin exists.
|
||||
Missing join should raise AssertionError.
|
||||
"""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
basic_mocks["query_chain"].first.return_value = None
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AssertionError, match="TenantAccountJoin not found"):
|
||||
WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Logo Customisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tenant_info_should_include_custom_config_when_logo_allowed_and_admin(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""custom_config block should appear for OWNER/ADMIN when can_replace_logo is True."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
basic_mocks["get_features"].return_value = _make_feature(can_replace_logo=True)
|
||||
basic_mocks["has_roles"].return_value = True
|
||||
tenant = _make_tenant(
|
||||
custom_config={
|
||||
"replace_webapp_logo": True,
|
||||
"remove_webapp_brand": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert "custom_config" in result
|
||||
assert result["custom_config"]["remove_webapp_brand"] is True
|
||||
expected_logo_url = f"{FILES_BASE_URL}/files/workspaces/{TENANT_ID}/webapp-logo"
|
||||
assert result["custom_config"]["replace_webapp_logo"] == expected_logo_url
|
||||
|
||||
|
||||
def test_get_tenant_info_should_set_replace_webapp_logo_to_none_when_flag_absent(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""replace_webapp_logo should be None when custom_config_dict does not have the key."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
basic_mocks["get_features"].return_value = _make_feature(can_replace_logo=True)
|
||||
basic_mocks["has_roles"].return_value = True
|
||||
tenant = _make_tenant(custom_config={}) # no replace_webapp_logo key
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["custom_config"]["replace_webapp_logo"] is None
|
||||
|
||||
|
||||
def test_get_tenant_info_should_not_include_custom_config_when_logo_not_allowed(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""custom_config should be absent when can_replace_logo is False."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
basic_mocks["get_features"].return_value = _make_feature(can_replace_logo=False)
|
||||
basic_mocks["has_roles"].return_value = True
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert "custom_config" not in result
|
||||
|
||||
|
||||
def test_get_tenant_info_should_not_include_custom_config_when_user_not_admin(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""custom_config block is gated on OWNER or ADMIN role."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
basic_mocks["get_features"].return_value = _make_feature(can_replace_logo=True)
|
||||
basic_mocks["has_roles"].return_value = False # regular member
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert "custom_config" not in result
|
||||
|
||||
|
||||
def test_get_tenant_info_should_use_files_url_for_logo_url(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""The logo URL should use dify_config.FILES_URL as the base."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
custom_base = "https://cdn.mycompany.io"
|
||||
basic_mocks["config"].FILES_URL = custom_base
|
||||
basic_mocks["get_features"].return_value = _make_feature(can_replace_logo=True)
|
||||
basic_mocks["has_roles"].return_value = True
|
||||
tenant = _make_tenant(custom_config={"replace_webapp_logo": True})
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["custom_config"]["replace_webapp_logo"].startswith(custom_base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Cloud-Edition Credit Features
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLOUD_BILLING_PLAN_NON_SANDBOX = "professional" # any plan that is not SANDBOX
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cloud_mocks(mocker: MockerFixture, mock_current_user: SimpleNamespace) -> dict:
|
||||
"""Patches for CLOUD edition tests, billing plan = professional by default."""
|
||||
mocker.patch(CURRENT_USER_PATH, mock_current_user)
|
||||
|
||||
mock_db_session = mocker.patch(f"{DB_PATH}.session")
|
||||
mock_query_chain = MagicMock()
|
||||
mock_db_session.query.return_value = mock_query_chain
|
||||
mock_query_chain.where.return_value = mock_query_chain
|
||||
mock_query_chain.first.return_value = _make_tenant_account_join(role="owner")
|
||||
|
||||
mock_feature = mocker.patch(
|
||||
FEATURE_SERVICE_PATH,
|
||||
return_value=_make_feature(
|
||||
can_replace_logo=False,
|
||||
next_credit_reset_date="2025-02-01",
|
||||
billing_plan=CLOUD_BILLING_PLAN_NON_SANDBOX,
|
||||
),
|
||||
)
|
||||
mocker.patch(TENANT_SERVICE_PATH, return_value=False)
|
||||
mock_config = mocker.patch(DIFY_CONFIG_PATH)
|
||||
mock_config.EDITION = "CLOUD"
|
||||
mock_config.FILES_URL = FILES_BASE_URL
|
||||
|
||||
return {
|
||||
"db_session": mock_db_session,
|
||||
"query_chain": mock_query_chain,
|
||||
"get_features": mock_feature,
|
||||
"config": mock_config,
|
||||
}
|
||||
|
||||
|
||||
def test_get_tenant_info_should_add_next_credit_reset_date_in_cloud_edition(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""next_credit_reset_date should be present in CLOUD edition."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
mocker.patch(
|
||||
CREDIT_POOL_SERVICE_PATH,
|
||||
side_effect=[None, None], # both paid and trial pools absent
|
||||
)
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["next_credit_reset_date"] == "2025-02-01"
|
||||
|
||||
|
||||
def test_get_tenant_info_should_use_paid_pool_when_plan_is_not_sandbox_and_pool_not_full(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""trial_credits/trial_credits_used come from the paid pool when conditions are met."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
paid_pool = _make_pool(quota_limit=1000, quota_used=200)
|
||||
mocker.patch(CREDIT_POOL_SERVICE_PATH, return_value=paid_pool)
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 1000
|
||||
assert result["trial_credits_used"] == 200
|
||||
|
||||
|
||||
def test_get_tenant_info_should_use_paid_pool_when_quota_limit_is_infinite(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""quota_limit == -1 means unlimited; service should still use the paid pool."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
paid_pool = _make_pool(quota_limit=-1, quota_used=999)
|
||||
mocker.patch(CREDIT_POOL_SERVICE_PATH, side_effect=[paid_pool, None])
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == -1
|
||||
assert result["trial_credits_used"] == 999
|
||||
|
||||
|
||||
def test_get_tenant_info_should_fall_back_to_trial_pool_when_paid_pool_is_full(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""When paid pool is exhausted (used >= limit), switch to trial pool."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
paid_pool = _make_pool(quota_limit=500, quota_used=500) # exactly full
|
||||
trial_pool = _make_pool(quota_limit=100, quota_used=10)
|
||||
mocker.patch(CREDIT_POOL_SERVICE_PATH, side_effect=[paid_pool, trial_pool])
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 100
|
||||
assert result["trial_credits_used"] == 10
|
||||
|
||||
|
||||
def test_get_tenant_info_should_fall_back_to_trial_pool_when_paid_pool_is_none(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""When paid_pool is None, fall back to trial pool."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
trial_pool = _make_pool(quota_limit=50, quota_used=5)
|
||||
mocker.patch(CREDIT_POOL_SERVICE_PATH, side_effect=[None, trial_pool])
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 50
|
||||
assert result["trial_credits_used"] == 5
|
||||
|
||||
|
||||
def test_get_tenant_info_should_fall_back_to_trial_pool_for_sandbox_plan(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""
|
||||
When the subscription plan IS SANDBOX, the paid pool branch is skipped
|
||||
entirely and we fall back to the trial pool.
|
||||
"""
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange — override billing plan to SANDBOX
|
||||
cloud_mocks["get_features"].return_value = _make_feature(
|
||||
next_credit_reset_date="2025-02-01",
|
||||
billing_plan=CloudPlan.SANDBOX,
|
||||
)
|
||||
paid_pool = _make_pool(quota_limit=1000, quota_used=0)
|
||||
trial_pool = _make_pool(quota_limit=200, quota_used=20)
|
||||
mocker.patch(CREDIT_POOL_SERVICE_PATH, side_effect=[paid_pool, trial_pool])
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result["trial_credits"] == 200
|
||||
assert result["trial_credits_used"] == 20
|
||||
|
||||
|
||||
def test_get_tenant_info_should_omit_trial_credits_when_both_pools_are_none(
|
||||
mocker: MockerFixture,
|
||||
cloud_mocks: dict,
|
||||
) -> None:
|
||||
"""When both paid and trial pools are absent, trial_credits should not be set."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
mocker.patch(CREDIT_POOL_SERVICE_PATH, side_effect=[None, None])
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert "trial_credits" not in result
|
||||
assert "trial_credits_used" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Self-hosted / Non-Cloud Edition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tenant_info_should_not_include_cloud_fields_in_self_hosted(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""next_credit_reset_date and trial_credits should NOT appear in SELF_HOSTED mode."""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange (basic_mocks already sets EDITION = "SELF_HOSTED")
|
||||
tenant = _make_tenant()
|
||||
|
||||
# Act
|
||||
result = _tenant_info(WorkspaceService.get_tenant_info(tenant))
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert "next_credit_reset_date" not in result
|
||||
assert "trial_credits" not in result
|
||||
assert "trial_credits_used" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. DB query integrity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tenant_info_should_query_tenant_account_join_with_correct_ids(
|
||||
mocker: MockerFixture,
|
||||
basic_mocks: dict,
|
||||
) -> None:
|
||||
"""
|
||||
The DB query for TenantAccountJoin must be scoped to the correct
|
||||
tenant_id and current_user.id.
|
||||
"""
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
# Arrange
|
||||
tenant = _make_tenant(tenant_id="my-special-tenant")
|
||||
mock_current_user = mocker.patch(CURRENT_USER_PATH)
|
||||
mock_current_user.id = "special-user-id"
|
||||
|
||||
# Act
|
||||
WorkspaceService.get_tenant_info(tenant)
|
||||
|
||||
# Assert — db.session.query was invoked (at least once)
|
||||
basic_mocks["db_session"].query.assert_called()
|
||||
@@ -488,7 +488,8 @@ ALIYUN_OSS_REGION=ap-southeast-1
|
||||
ALIYUN_OSS_AUTH_VERSION=v4
|
||||
# Don't start with '/'. OSS doesn't support leading slash in object names.
|
||||
ALIYUN_OSS_PATH=your-path
|
||||
ALIYUN_CLOUDBOX_ID=your-cloudbox-id
|
||||
# Optional CloudBox ID for Aliyun OSS, DO NOT enable it if you are not using CloudBox.
|
||||
#ALIYUN_CLOUDBOX_ID=your-cloudbox-id
|
||||
|
||||
# Tencent COS Configuration
|
||||
#
|
||||
|
||||
@@ -275,6 +275,7 @@ services:
|
||||
# Use the shared environment variables.
|
||||
<<: *shared-api-worker-env
|
||||
DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin}
|
||||
DB_SSL_MODE: ${DB_SSL_MODE:-disable}
|
||||
SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002}
|
||||
SERVER_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}
|
||||
MAX_PLUGIN_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
|
||||
@@ -146,7 +146,6 @@ x-shared-env: &shared-api-worker-env
|
||||
ALIYUN_OSS_REGION: ${ALIYUN_OSS_REGION:-ap-southeast-1}
|
||||
ALIYUN_OSS_AUTH_VERSION: ${ALIYUN_OSS_AUTH_VERSION:-v4}
|
||||
ALIYUN_OSS_PATH: ${ALIYUN_OSS_PATH:-your-path}
|
||||
ALIYUN_CLOUDBOX_ID: ${ALIYUN_CLOUDBOX_ID:-your-cloudbox-id}
|
||||
TENCENT_COS_BUCKET_NAME: ${TENCENT_COS_BUCKET_NAME:-your-bucket-name}
|
||||
TENCENT_COS_SECRET_KEY: ${TENCENT_COS_SECRET_KEY:-your-secret-key}
|
||||
TENCENT_COS_SECRET_ID: ${TENCENT_COS_SECRET_ID:-your-secret-id}
|
||||
@@ -985,6 +984,7 @@ services:
|
||||
# Use the shared environment variables.
|
||||
<<: *shared-api-worker-env
|
||||
DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin}
|
||||
DB_SSL_MODE: ${DB_SSL_MODE:-disable}
|
||||
SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002}
|
||||
SERVER_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}
|
||||
MAX_PLUGIN_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ appId: string }>
|
||||
}) => {
|
||||
const { appId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="workflow" resourceId={appId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
}>>([])
|
||||
|
||||
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
|
||||
const navConfig = [
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
},
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
},
|
||||
]
|
||||
const navConfig = []
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
})
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
})
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
return navConfig
|
||||
}, [t])
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ datasetId: string }>
|
||||
}) => {
|
||||
const { datasetId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiFileTextFill,
|
||||
RiFileTextLine,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
} from '@remixicon/react'
|
||||
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
]
|
||||
|
||||
if (datasetRes?.provider !== 'external') {
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
})
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
})
|
||||
return [
|
||||
{
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.evaluation', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
disabled: false,
|
||||
},
|
||||
...baseNavigation,
|
||||
]
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
|
||||
@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} section="evaluation" />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} section="orchestrate" />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
@@ -104,10 +108,11 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
{renderHeader?.(appSidebarExpand)}
|
||||
{!renderHeader && iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@@ -136,7 +141,8 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
{renderNavigation?.(appSidebarExpand)}
|
||||
{!renderNavigation && navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
|
||||
@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: AppIconSelection
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '✨',
|
||||
background: '#FFFFFF',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
author: 'Dify',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
icon: '🤖',
|
||||
iconBackground: '#F0FDF9',
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
icon_info: {
|
||||
icon: '✨',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
author: 'Dify',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
icon: '🤖',
|
||||
iconBackground: '#F0FDF9',
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
197
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
197
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: AppIconSelection = {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
icon: snippet.icon
|
||||
? {
|
||||
type: 'emoji' as const,
|
||||
icon: snippet.icon,
|
||||
background: snippet.iconBackground || FALLBACK_ICON.background,
|
||||
}
|
||||
: FALLBACK_ICON,
|
||||
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-[400px]">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
55
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
55
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
|
||||
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
|
||||
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
|
||||
<div className={cn('shrink-0', !expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType="emoji"
|
||||
icon={snippet.icon}
|
||||
background={snippet.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && <SnippetInfoDropdown snippet={snippet} />}
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
@@ -15,10 +15,13 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -36,6 +39,7 @@ const mockQueryState = {
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQueryState,
|
||||
@@ -45,6 +49,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
|
||||
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
||||
let mockDragging = false
|
||||
|
||||
vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
||||
mockOnDSLFileDropped = onDSLFileDropped
|
||||
@@ -54,11 +59,13 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockFetchSnippetNextPage = vi.fn()
|
||||
|
||||
const mockServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
@@ -100,6 +107,7 @@ vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: defaultAppData,
|
||||
isLoading: mockServiceState.isLoading,
|
||||
isFetching: mockServiceState.isFetching,
|
||||
isFetchingNextPage: mockServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockServiceState.hasNextPage,
|
||||
@@ -112,6 +120,57 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSnippetServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
const defaultSnippetData = {
|
||||
pages: [{
|
||||
data: [
|
||||
{
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
|
||||
author: '',
|
||||
updatedAt: '2024-01-02 10:00',
|
||||
usage: '19',
|
||||
icon: '🪄',
|
||||
iconBackground: '#E0EAFF',
|
||||
status: undefined,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}],
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useInfiniteSnippetList: () => ({
|
||||
data: defaultSnippetData,
|
||||
isLoading: mockSnippetServiceState.isLoading,
|
||||
isFetching: mockSnippetServiceState.isFetching,
|
||||
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchSnippetNextPage,
|
||||
hasNextPage: mockSnippetServiceState.hasNextPage,
|
||||
error: mockSnippetServiceState.error,
|
||||
}),
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
||||
}))
|
||||
@@ -133,13 +192,21 @@ vi.mock('@/next/dynamic', () => ({
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
}
|
||||
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'create-dsl-modal' },
|
||||
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
|
||||
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return () => null
|
||||
},
|
||||
}))
|
||||
@@ -188,9 +255,8 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const renderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<List />, { searchParams })
|
||||
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
|
||||
return renderWithNuqs(<List {...props} />, { searchParams })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
@@ -202,284 +268,62 @@ describe('List', () => {
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
mockOnDSLFileDropped = null
|
||||
mockServiceState.error = null
|
||||
mockServiceState.hasNextPage = false
|
||||
mockServiceState.isLoading = false
|
||||
mockServiceState.isFetching = false
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockSnippetServiceState.error = null
|
||||
mockSnippetServiceState.hasNextPage = false
|
||||
mockSnippetServiceState.isLoading = false
|
||||
mockSnippetServiceState.isFetching = false
|
||||
mockSnippetServiceState.isFetchingNextPage = false
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
describe('Apps Mode', () => {
|
||||
it('should render the apps route switch, dropdown filters, and app cards', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
it('should update the category query when selecting an app type from the dropdown', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByText('app.studio.filters.types'))
|
||||
fireEvent.click(await screen.findByText('app.types.workflow'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
const { onUrlUpdate } = renderList('?category=workflow')
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
// nuqs removes the default value ('all') from URL params
|
||||
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
it('should keep the creators dropdown visual-only and not update app query state', async () => {
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
fireEvent.click(screen.getByText('app.studio.filters.creators'))
|
||||
fireEvent.click(await screen.findByText('Evan'))
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(mockSetQuery).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search clear button click', () => {
|
||||
mockQueryState.keywords = 'existing search'
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Editor User', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dataset Operator Behavior', () => {
|
||||
it('should not trigger redirect at component level for dataset operators', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local Storage Refresh', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
|
||||
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
|
||||
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
||||
]
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
onUrlUpdate.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('App List Display', () => {
|
||||
it('should display all app cards from data', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DSL File Drop', () => {
|
||||
it('should handle DSL file drop and show modal', () => {
|
||||
it('should render and close the DSL import modal when a file is dropped', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
@@ -489,98 +333,50 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal when onClose is called', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||
renderList()
|
||||
describe('Snippets Mode', () => {
|
||||
it('should render the snippets create card and snippet card from the real query hook', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
expect(screen.getByText('snippet.create')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
|
||||
mockSnippetServiceState.hasNextPage = true
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalled()
|
||||
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when not intersecting', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
it('should not render app-only controls in snippets mode', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when loading', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
it('should not fetch the next snippet page when no more data is available', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
act(() => {
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error State', () => {
|
||||
it('should handle error state in useEffect', () => {
|
||||
mockServiceState.error = new Error('Test error')
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
71
web/app/components/apps/app-type-filter.tsx
Normal file
71
web/app/components/apps/app-type-filter.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
activeTab: import('./app-type-filter-shared').AppListCategory
|
||||
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
|
||||
}
|
||||
|
||||
const AppTypeFilter = ({
|
||||
activeTab,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === activeTab)
|
||||
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppTypeFilter
|
||||
128
web/app/components/apps/creators-filter.tsx
Normal file
128
web/app/components/apps/creators-filter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuCheckboxItemIndicator,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
isYou?: boolean
|
||||
avatarClassName: string
|
||||
}
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
const creatorOptions: CreatorOption[] = [
|
||||
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
|
||||
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
|
||||
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
|
||||
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
|
||||
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
|
||||
]
|
||||
|
||||
const CreatorsFilter = () => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
|
||||
}, [keywords])
|
||||
|
||||
const selectedCount = selectedCreatorIds.length
|
||||
const triggerLabel = selectedCount > 0
|
||||
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
|
||||
: t('studio.filters.creators', { ns: 'app' })
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
setSelectedCreatorIds((prev) => {
|
||||
if (prev.includes(creatorId))
|
||||
return prev.filter(id => id !== creatorId)
|
||||
return [...prev, creatorId]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
setSelectedCreatorIds([])
|
||||
setKeywords('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-2 p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedCreatorIds.length === 0}
|
||||
onCheckedChange={resetCreators}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
{filteredCreators.map(creator => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={creator.id}
|
||||
checked={selectedCreatorIds.includes(creator.id)}
|
||||
onCheckedChange={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
|
||||
<span className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@@ -12,14 +12,24 @@ import dynamic from '@/next/dynamic'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import List from './list'
|
||||
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
|
||||
const Apps = () => {
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@@ -103,7 +113,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { App } from '@/types/app'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
@@ -16,15 +16,21 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { useInfiniteSnippetList } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetCard from '../snippets/components/snippet-card'
|
||||
import SnippetCreateCard from '../snippets/components/snippet-create-card'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import AppTypeFilter from './app-type-filter'
|
||||
import { parseAsAppListCategory } from './app-type-filter-shared'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import NewAppCard from './new-app-card'
|
||||
import StudioRouteSwitch from './studio-route-switch'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
@@ -33,25 +39,17 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isAppsPage = pageType === 'apps'
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
@@ -61,18 +59,22 @@ const List: FC<Props> = ({
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [appKeywords, setAppKeywords] = useState(keywords)
|
||||
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
|
||||
const [snippetKeywords, setSnippetKeywords] = useState('')
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const setKeywords = useCallback((nextKeywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
|
||||
}, [setQuery])
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs }))
|
||||
|
||||
const setTagIDs = useCallback((nextTagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
|
||||
}, [setQuery])
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
@@ -83,15 +85,15 @@ const List: FC<Props> = ({
|
||||
const { dragging } = useDSLDragDrop({
|
||||
onDSLFileDropped: handleDSLFileDropped,
|
||||
containerRef,
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
enabled: isAppsPage && isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const appListQueryParams = {
|
||||
page: 1,
|
||||
limit: 30,
|
||||
name: searchKeywords,
|
||||
name: appKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
is_created_by_me: queryIsCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}
|
||||
|
||||
@@ -104,159 +106,214 @@ const List: FC<Props> = ({
|
||||
hasNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
|
||||
} = useInfiniteAppList(appListQueryParams, {
|
||||
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
|
||||
})
|
||||
|
||||
const {
|
||||
data: snippetData,
|
||||
isLoading: isSnippetListLoading,
|
||||
isFetching: isSnippetListFetching,
|
||||
isFetchingNextPage: isSnippetListFetchingNextPage,
|
||||
fetchNextPage: fetchSnippetNextPage,
|
||||
hasNextPage: hasSnippetNextPage,
|
||||
error: snippetError,
|
||||
} = useInfiniteSnippetList({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: snippetKeywords || undefined,
|
||||
}, {
|
||||
enabled: !isAppsPage,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (controlRefreshList > 0) {
|
||||
if (isAppsPage && controlRefreshList > 0)
|
||||
refetch()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlRefreshList])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
|
||||
]
|
||||
}, [controlRefreshList, isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppsPage)
|
||||
return
|
||||
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
refetch()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
|
||||
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
|
||||
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
|
||||
const currentError = isAppsPage ? error : snippetError
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
if (observer)
|
||||
observer.disconnect()
|
||||
if (currentError) {
|
||||
observer?.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (anchorRef.current && containerRef.current) {
|
||||
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
|
||||
const containerHeight = containerRef.current.clientHeight
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
||||
fetchNextPage()
|
||||
if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
|
||||
if (isAppsPage)
|
||||
fetchNextPage()
|
||||
else
|
||||
fetchSnippetNextPage()
|
||||
}
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
threshold: 0.1, // Trigger when 10% of the anchor element is visible
|
||||
threshold: 0.1,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
const { run: handleAppSearch } = useDebounceFn((value: string) => {
|
||||
setAppKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn(() => {
|
||||
setTagIDs(tagFilterValue)
|
||||
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
|
||||
setSnippetKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleTagsChange = (value: string[]) => {
|
||||
|
||||
const handleKeywordsChange = useCallback((value: string) => {
|
||||
if (isAppsPage) {
|
||||
setKeywords(value)
|
||||
handleAppSearch(value)
|
||||
return
|
||||
}
|
||||
|
||||
setSnippetKeywordsInput(value)
|
||||
handleSnippetSearch(value)
|
||||
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
|
||||
setTagIDs(value)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleTagsChange = useCallback((value: string[]) => {
|
||||
setTagFilterValue(value)
|
||||
handleTagsUpdate()
|
||||
}
|
||||
handleTagsUpdate(value)
|
||||
}, [handleTagsUpdate])
|
||||
|
||||
const handleCreatedByMeChange = useCallback(() => {
|
||||
const newValue = !isCreatedByMe
|
||||
setIsCreatedByMe(newValue)
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
const appItems = useMemo<App[]>(() => {
|
||||
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
|
||||
}, [data?.pages])
|
||||
|
||||
const pages = data?.pages ?? []
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const snippetItems = useMemo(() => {
|
||||
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
|
||||
}, [snippetData?.pages])
|
||||
|
||||
const showSkeleton = isAppsPage
|
||||
? (isLoading || (isFetching && data?.pages?.length === 0))
|
||||
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
|
||||
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
|
||||
const hasAnySnippet = snippetItems.length > 0
|
||||
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
|
||||
</div>
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StudioRouteSwitch
|
||||
pageType={pageType}
|
||||
appsLabel={t('studio.apps', { ns: 'app' })}
|
||||
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
|
||||
/>
|
||||
{isAppsPage && (
|
||||
<AppTypeFilter
|
||||
activeTab={activeTab}
|
||||
onChange={(value) => {
|
||||
void setActiveTab(value)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CreatorsFilter />
|
||||
{isAppsPage && (
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
|
||||
value={currentKeywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
isAppsPage && !hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
isAppsPage
|
||||
? (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)
|
||||
: <SnippetCreateCard />
|
||||
)}
|
||||
{(() => {
|
||||
if (showSkeleton)
|
||||
return <AppCardSkeleton count={6} />
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))
|
||||
}
|
||||
{showSkeleton && <AppCardSkeleton count={6} />}
|
||||
|
||||
// No apps - show empty state
|
||||
return <Empty />
|
||||
})()}
|
||||
{isFetchingNextPage && (
|
||||
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
|
||||
<SnippetCard key={snippet.id} snippet={snippet} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
|
||||
|
||||
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
|
||||
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAppsPage && isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
{!isAppsPage && isSnippetListFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
{isAppsPage && isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 py-4',
|
||||
dragging ? 'text-text-accent' : 'text-text-quaternary',
|
||||
)}
|
||||
role="region"
|
||||
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
|
||||
>
|
||||
@@ -264,17 +321,18 @@ const List: FC<Props> = ({
|
||||
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className="h-0"> </div>
|
||||
{showTagManagementModal && (
|
||||
{isAppsPage && showTagManagementModal && (
|
||||
<TagManagementModal type="app" show={showTagManagementModal} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateFromDSLModal && (
|
||||
{isAppsPage && showCreateFromDSLModal && (
|
||||
<CreateFromDSLModal
|
||||
show={showCreateFromDSLModal}
|
||||
onClose={() => {
|
||||
|
||||
44
web/app/components/apps/studio-route-switch.tsx
Normal file
44
web/app/components/apps/studio-route-switch.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import type { StudioPageType } from '.'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
pageType: StudioPageType
|
||||
appsLabel: string
|
||||
snippetsLabel: string
|
||||
}
|
||||
|
||||
const StudioRouteSwitch = ({
|
||||
pageType,
|
||||
appsLabel,
|
||||
snippetsLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
|
||||
<Link
|
||||
href="/apps"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'apps' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{appsLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'snippets' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{snippetsLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioRouteSwitch
|
||||
112
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
112
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Evaluation from '..'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { useEvaluationStore } from '../store'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({
|
||||
data: [{
|
||||
provider: 'openai',
|
||||
models: [{ model: 'gpt-4o-mini' }],
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
|
||||
<div data-testid="evaluation-model-selector">
|
||||
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Evaluation', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
})
|
||||
|
||||
it('should search, add metrics, and create a batch history record', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
|
||||
|
||||
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
|
||||
target: { value: 'does-not-exist' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
|
||||
target: { value: 'faith' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
|
||||
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
|
||||
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render time placeholders and hide the value row for empty operators', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-2'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
const timeField = config.fieldOptions.find(field => field.type === 'time')!
|
||||
let groupId = ''
|
||||
let itemId = ''
|
||||
|
||||
act(() => {
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
|
||||
|
||||
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
|
||||
groupId = group.id
|
||||
itemId = group.items[0].id
|
||||
|
||||
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
|
||||
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
|
||||
})
|
||||
|
||||
let rerender: ReturnType<typeof render>['rerender']
|
||||
act(() => {
|
||||
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
|
||||
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
96
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
96
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import {
|
||||
getAllowedOperators,
|
||||
isCustomMetricConfigured,
|
||||
requiresConditionValue,
|
||||
useEvaluationStore,
|
||||
} from '../store'
|
||||
|
||||
describe('evaluation store', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
})
|
||||
|
||||
it('should configure a custom metric mapping to a valid state', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addCustomMetric(resourceType, resourceId)
|
||||
|
||||
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
|
||||
expect(initialMetric).toBeDefined()
|
||||
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
|
||||
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
|
||||
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
|
||||
sourceFieldId: config.fieldOptions[0].id,
|
||||
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
|
||||
})
|
||||
|
||||
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
|
||||
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
|
||||
})
|
||||
|
||||
it('should add and remove builtin metrics', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-2'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
|
||||
|
||||
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
|
||||
expect(addedMetric).toBeDefined()
|
||||
|
||||
store.removeMetric(resourceType, resourceId, addedMetric!.id)
|
||||
|
||||
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('should update condition groups and adapt operators to field types', () => {
|
||||
const resourceType = 'pipeline'
|
||||
const resourceId = 'dataset-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
|
||||
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
|
||||
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
|
||||
store.addConditionGroup(resourceType, resourceId)
|
||||
|
||||
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
|
||||
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
|
||||
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
|
||||
|
||||
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
|
||||
expect(updatedGroup.logicalOperator).toBe('or')
|
||||
expect(updatedGroup.items[0].operator).toBe('is')
|
||||
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
|
||||
})
|
||||
|
||||
it('should support time fields and clear values for empty operators', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-3'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
|
||||
const timeField = config.fieldOptions.find(field => field.type === 'time')!
|
||||
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
|
||||
|
||||
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
|
||||
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
|
||||
|
||||
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
|
||||
|
||||
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
|
||||
expect(requiresConditionValue('is_empty')).toBe(false)
|
||||
expect(updatedItem.value).toBeNull()
|
||||
})
|
||||
})
|
||||
1017
web/app/components/evaluation/index.tsx
Normal file
1017
web/app/components/evaluation/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
184
web/app/components/evaluation/mock.ts
Normal file
184
web/app/components/evaluation/mock.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
EvaluationFieldOption,
|
||||
EvaluationMockConfig,
|
||||
EvaluationResourceType,
|
||||
MetricOption,
|
||||
} from './types'
|
||||
|
||||
const judgeModels = [
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
label: 'GPT-4.1 mini',
|
||||
provider: 'OpenAI',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet',
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
label: 'Gemini 2.0 Flash',
|
||||
provider: 'Google',
|
||||
},
|
||||
]
|
||||
|
||||
const builtinMetrics: MetricOption[] = [
|
||||
{
|
||||
id: 'answer-correctness',
|
||||
label: 'Answer Correctness',
|
||||
description: 'Compares the response with the expected answer and scores factual alignment.',
|
||||
group: 'quality',
|
||||
badges: ['LLM', 'Built-in'],
|
||||
},
|
||||
{
|
||||
id: 'faithfulness',
|
||||
label: 'Faithfulness',
|
||||
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
|
||||
group: 'quality',
|
||||
badges: ['LLM', 'Retrieval'],
|
||||
},
|
||||
{
|
||||
id: 'relevance',
|
||||
label: 'Relevance',
|
||||
description: 'Evaluates how directly the answer addresses the original request.',
|
||||
group: 'quality',
|
||||
badges: ['LLM'],
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
label: 'Latency',
|
||||
description: 'Captures runtime responsiveness for the full execution path.',
|
||||
group: 'operations',
|
||||
badges: ['System'],
|
||||
},
|
||||
{
|
||||
id: 'token-usage',
|
||||
label: 'Token Usage',
|
||||
description: 'Tracks prompt and completion token consumption for the run.',
|
||||
group: 'operations',
|
||||
badges: ['System'],
|
||||
},
|
||||
{
|
||||
id: 'tool-success-rate',
|
||||
label: 'Tool Success Rate',
|
||||
description: 'Measures whether each required tool invocation finishes without failure.',
|
||||
group: 'operations',
|
||||
badges: ['Workflow'],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowOptions = [
|
||||
{
|
||||
id: 'workflow-precision-review',
|
||||
label: 'Precision Review Workflow',
|
||||
description: 'Custom evaluator for nuanced quality review.',
|
||||
targetVariables: [
|
||||
{ id: 'query', label: 'query' },
|
||||
{ id: 'answer', label: 'answer' },
|
||||
{ id: 'reference', label: 'reference' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow-risk-review',
|
||||
label: 'Risk Review Workflow',
|
||||
description: 'Custom evaluator for policy and escalation checks.',
|
||||
targetVariables: [
|
||||
{ id: 'input', label: 'input' },
|
||||
{ id: 'output', label: 'output' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowFields: EvaluationFieldOption[] = [
|
||||
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
|
||||
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
|
||||
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
|
||||
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
|
||||
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
|
||||
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
const pipelineFields: EvaluationFieldOption[] = [
|
||||
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
|
||||
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
|
||||
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
|
||||
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
|
||||
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
|
||||
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
|
||||
]
|
||||
|
||||
const snippetFields: EvaluationFieldOption[] = [
|
||||
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
|
||||
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
|
||||
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
|
||||
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
|
||||
if (fieldType === 'number')
|
||||
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
|
||||
|
||||
if (fieldType === 'time')
|
||||
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
|
||||
|
||||
if (fieldType === 'boolean' || fieldType === 'enum')
|
||||
return ['is', 'is_not']
|
||||
|
||||
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
|
||||
}
|
||||
|
||||
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
|
||||
return getComparisonOperators(fieldType)[0]
|
||||
}
|
||||
|
||||
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
|
||||
if (resourceType === 'pipeline') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: pipelineFields,
|
||||
templateFileName: 'pipeline-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per retrieval scenario.',
|
||||
'Provide the expected source or target chunk for each case.',
|
||||
'Keep numeric metrics in plain number format.',
|
||||
],
|
||||
historySummaryLabel: 'Pipeline evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType === 'snippet') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: snippetFields,
|
||||
templateFileName: 'snippet-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per snippet execution case.',
|
||||
'Provide the expected final content or acceptance rule.',
|
||||
'Keep optional fields empty when not used.',
|
||||
],
|
||||
historySummaryLabel: 'Snippet evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: workflowFields,
|
||||
templateFileName: 'workflow-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per workflow test case.',
|
||||
'Provide both user input and expected answer when available.',
|
||||
'Keep boolean columns as true or false.',
|
||||
],
|
||||
historySummaryLabel: 'Workflow evaluation batch',
|
||||
}
|
||||
}
|
||||
635
web/app/components/evaluation/store.ts
Normal file
635
web/app/components/evaluation/store.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
import type {
|
||||
BatchTestRecord,
|
||||
ComparisonOperator,
|
||||
EvaluationFieldOption,
|
||||
EvaluationMetric,
|
||||
EvaluationResourceState,
|
||||
EvaluationResourceType,
|
||||
JudgmentConditionGroup,
|
||||
} from './types'
|
||||
import { create } from 'zustand'
|
||||
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
|
||||
|
||||
type EvaluationStore = {
|
||||
resources: Record<string, EvaluationResourceState>
|
||||
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
|
||||
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
|
||||
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
|
||||
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
updateCustomMetricMapping: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
mappingId: string,
|
||||
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
|
||||
) => void
|
||||
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
|
||||
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
|
||||
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
|
||||
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
|
||||
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
|
||||
updateConditionValue: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
value: string | number | boolean | null,
|
||||
) => void
|
||||
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
|
||||
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
|
||||
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
}
|
||||
|
||||
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
|
||||
const initialResourceCache: Record<string, EvaluationResourceState> = {}
|
||||
|
||||
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
|
||||
|
||||
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
|
||||
|
||||
const getConditionValue = (
|
||||
field: EvaluationFieldOption | undefined,
|
||||
operator: ComparisonOperator,
|
||||
previousValue: string | number | boolean | null = null,
|
||||
) => {
|
||||
if (!field || !requiresConditionValue(operator))
|
||||
return null
|
||||
|
||||
if (field.type === 'boolean')
|
||||
return typeof previousValue === 'boolean' ? previousValue : null
|
||||
|
||||
if (field.type === 'enum')
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
|
||||
if (field.type === 'number')
|
||||
return typeof previousValue === 'number' ? previousValue : null
|
||||
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
}
|
||||
|
||||
const buildConditionItem = (resourceType: EvaluationResourceType) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
|
||||
const operator = field ? getDefaultOperator(field.type) : 'contains'
|
||||
|
||||
return {
|
||||
id: createId('condition'),
|
||||
fieldId: field?.id ?? null,
|
||||
operator,
|
||||
value: getConditionValue(field, operator),
|
||||
}
|
||||
}
|
||||
|
||||
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const defaultMetric = config.builtinMetrics[0]
|
||||
|
||||
return {
|
||||
judgeModelId: null,
|
||||
metrics: defaultMetric
|
||||
? [{
|
||||
id: createId('metric'),
|
||||
optionId: defaultMetric.id,
|
||||
kind: 'builtin',
|
||||
label: defaultMetric.label,
|
||||
description: defaultMetric.description,
|
||||
badges: defaultMetric.badges,
|
||||
}]
|
||||
: [],
|
||||
conditions: [{
|
||||
id: createId('group'),
|
||||
logicalOperator: 'and',
|
||||
items: [buildConditionItem(resourceType)],
|
||||
}],
|
||||
activeBatchTab: 'input-fields',
|
||||
uploadedFileName: null,
|
||||
batchRecords: [],
|
||||
}
|
||||
}
|
||||
|
||||
const withResourceState = (
|
||||
resources: EvaluationStore['resources'],
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resourceKey,
|
||||
resource: resources[resourceKey] ?? buildInitialState(resourceType),
|
||||
}
|
||||
}
|
||||
|
||||
const updateMetric = (
|
||||
metrics: EvaluationMetric[],
|
||||
metricId: string,
|
||||
updater: (metric: EvaluationMetric) => EvaluationMetric,
|
||||
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
|
||||
|
||||
const updateConditionGroup = (
|
||||
groups: JudgmentConditionGroup[],
|
||||
groupId: string,
|
||||
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
|
||||
) => groups.map(group => group.id === groupId ? updater(group) : group)
|
||||
|
||||
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
|
||||
if (metric.kind !== 'custom-workflow')
|
||||
return true
|
||||
|
||||
if (!metric.customConfig?.workflowId)
|
||||
return false
|
||||
|
||||
return metric.customConfig.mappings.length > 0
|
||||
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
return !!state.judgeModelId
|
||||
&& state.metrics.length > 0
|
||||
&& state.metrics.every(isCustomMetricConfigured)
|
||||
&& state.conditions.some(group => group.items.length > 0)
|
||||
}
|
||||
|
||||
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
resources: {},
|
||||
ensureResource: (resourceType, resourceId) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
if (get().resources[resourceKey])
|
||||
return
|
||||
|
||||
set(state => ({
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: buildInitialState(resourceType),
|
||||
},
|
||||
}))
|
||||
},
|
||||
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
judgeModelId,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addBuiltinMetric: (resourceType, resourceId, optionId) => {
|
||||
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
|
||||
if (!option)
|
||||
return
|
||||
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
|
||||
return state
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: [
|
||||
...resource.metrics,
|
||||
{
|
||||
id: createId('metric'),
|
||||
optionId: option.id,
|
||||
kind: 'builtin',
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
badges: option.badges,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addCustomMetric: (resourceType, resourceId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: [
|
||||
...resource.metrics,
|
||||
{
|
||||
id: createId('metric'),
|
||||
optionId: createId('custom'),
|
||||
kind: 'custom-workflow',
|
||||
label: 'Custom Evaluator',
|
||||
description: 'Map workflow variables to your evaluation inputs.',
|
||||
badges: ['Workflow'],
|
||||
customConfig: {
|
||||
workflowId: null,
|
||||
mappings: [{
|
||||
id: createId('mapping'),
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeMetric: (resourceType, resourceId, metricId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: resource.metrics.filter(metric => metric.id !== metricId),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
workflowId,
|
||||
mappings: metric.customConfig.mappings.map(mapping => ({
|
||||
...mapping,
|
||||
targetVariableId: null,
|
||||
})),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: [
|
||||
...metric.customConfig.mappings,
|
||||
{
|
||||
id: createId('mapping'),
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addConditionGroup: (resourceType, resourceId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: [
|
||||
...resource.conditions,
|
||||
{
|
||||
id: createId('group'),
|
||||
logicalOperator: 'and',
|
||||
items: [buildConditionItem(resourceType)],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeConditionGroup: (resourceType, resourceId, groupId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: resource.conditions.filter(group => group.id !== groupId),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
logicalOperator,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addConditionItem: (resourceType, resourceId, groupId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: [
|
||||
...group.items,
|
||||
buildConditionItem(resourceType),
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => item.id !== itemId),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
|
||||
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.id !== itemId)
|
||||
return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
fieldId,
|
||||
operator: field ? getDefaultOperator(field.type) : item.operator,
|
||||
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.id !== itemId)
|
||||
return item
|
||||
|
||||
const field = fieldOptions.find(option => option.id === item.fieldId)
|
||||
|
||||
return {
|
||||
...item,
|
||||
operator,
|
||||
value: getConditionValue(field, operator, item.value),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setBatchTab: (resourceType, resourceId, tab) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
activeBatchTab: tab,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
uploadedFileName,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
runBatchTest: (resourceType, resourceId) => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const recordId = createId('batch')
|
||||
const nextRecord: BatchTestRecord = {
|
||||
id: recordId,
|
||||
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
|
||||
status: 'running',
|
||||
startedAt: new Date().toLocaleTimeString(),
|
||||
summary: config.historySummaryLabel,
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
activeBatchTab: 'history',
|
||||
batchRecords: [nextRecord, ...resource.batchRecords],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
batchRecords: resource.batchRecords.map(record => record.id === recordId
|
||||
? {
|
||||
...record,
|
||||
status: resource.metrics.length > 1 ? 'success' : 'failed',
|
||||
}
|
||||
: record),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}, 1200)
|
||||
},
|
||||
}))
|
||||
|
||||
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
|
||||
}
|
||||
|
||||
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
|
||||
|
||||
if (!field)
|
||||
return ['contains'] as ComparisonOperator[]
|
||||
|
||||
return getComparisonOperators(field.type)
|
||||
}
|
||||
117
web/app/components/evaluation/types.ts
Normal file
117
web/app/components/evaluation/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
|
||||
|
||||
export type MetricKind = 'builtin' | 'custom-workflow'
|
||||
|
||||
export type BatchTestTab = 'input-fields' | 'history'
|
||||
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
|
||||
|
||||
export type ComparisonOperator
|
||||
= | 'contains'
|
||||
| 'not_contains'
|
||||
| 'is'
|
||||
| 'is_not'
|
||||
| 'is_empty'
|
||||
| 'is_not_empty'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'greater_or_equal'
|
||||
| 'less_or_equal'
|
||||
| 'before'
|
||||
| 'after'
|
||||
|
||||
export type JudgeModelOption = {
|
||||
id: string
|
||||
label: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type MetricOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
group: string
|
||||
badges: string[]
|
||||
}
|
||||
|
||||
export type EvaluationWorkflowOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
targetVariables: Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type EvaluationFieldOption = {
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
type: FieldType
|
||||
options?: Array<{
|
||||
value: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type CustomMetricMapping = {
|
||||
id: string
|
||||
sourceFieldId: string | null
|
||||
targetVariableId: string | null
|
||||
}
|
||||
|
||||
export type CustomMetricConfig = {
|
||||
workflowId: string | null
|
||||
mappings: CustomMetricMapping[]
|
||||
}
|
||||
|
||||
export type EvaluationMetric = {
|
||||
id: string
|
||||
optionId: string
|
||||
kind: MetricKind
|
||||
label: string
|
||||
description: string
|
||||
badges: string[]
|
||||
customConfig?: CustomMetricConfig
|
||||
}
|
||||
|
||||
export type JudgmentConditionItem = {
|
||||
id: string
|
||||
fieldId: string | null
|
||||
operator: ComparisonOperator
|
||||
value: string | number | boolean | null
|
||||
}
|
||||
|
||||
export type JudgmentConditionGroup = {
|
||||
id: string
|
||||
logicalOperator: 'and' | 'or'
|
||||
items: JudgmentConditionItem[]
|
||||
}
|
||||
|
||||
export type BatchTestRecord = {
|
||||
id: string
|
||||
fileName: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
startedAt: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export type EvaluationResourceState = {
|
||||
judgeModelId: string | null
|
||||
metrics: EvaluationMetric[]
|
||||
conditions: JudgmentConditionGroup[]
|
||||
activeBatchTab: BatchTestTab
|
||||
uploadedFileName: string | null
|
||||
batchRecords: BatchTestRecord[]
|
||||
}
|
||||
|
||||
export type EvaluationMockConfig = {
|
||||
judgeModels: JudgeModelOption[]
|
||||
builtinMetrics: MetricOption[]
|
||||
workflowOptions: EvaluationWorkflowOption[]
|
||||
fieldOptions: EvaluationFieldOption[]
|
||||
templateFileName: string
|
||||
batchRequirements: string[]
|
||||
historySummaryLabel: string
|
||||
}
|
||||
@@ -107,7 +107,7 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="h-4 w-4" />}
|
||||
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app']}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
link="/apps"
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
|
||||
@@ -14,7 +14,7 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
|
||||
218
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
218
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import SnippetPage from '..'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
|
||||
const mockUseSnippetInit = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-snippet-init', () => ({
|
||||
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => ({
|
||||
flowId: 'snippet-1',
|
||||
flowType: 'snippet',
|
||||
fileSettings: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-snippet-refresh-draft', () => ({
|
||||
useSnippetRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({
|
||||
data: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-default-context">{children}</div>
|
||||
),
|
||||
WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => (
|
||||
<div data-testid="workflow-inner-context">
|
||||
<span data-testid="workflow-viewport-zoom">{viewport?.zoom ?? 'none'}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: {
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="app-sidebar">
|
||||
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
|
||||
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
|
||||
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{name}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
|
||||
<div data-testid="workflow-panel">
|
||||
<div data-testid="workflow-panel-left">{components?.left}</div>
|
||||
<div data-testid="workflow-panel-right">{components?.right}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
initialNodes: (nodes: unknown[]) => nodes,
|
||||
initialEdges: (edges: unknown[]) => edges,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const mockSnippetDetail: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
author: 'Evan',
|
||||
updatedAt: 'Updated 2h ago',
|
||||
usage: 'Used 19 times',
|
||||
icon: '🪄',
|
||||
iconBackground: '#E0EAFF',
|
||||
status: 'Draft',
|
||||
},
|
||||
graph: {
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputFields: [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
options: [],
|
||||
placeholder: 'Paste a source article URL',
|
||||
max_length: 256,
|
||||
},
|
||||
],
|
||||
uiMeta: {
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 2,
|
||||
autoSavedAt: 'Auto-saved · a few seconds ago',
|
||||
},
|
||||
}
|
||||
|
||||
describe('SnippetPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSnippetDetailStore.getState().reset()
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: mockSnippetDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the snippet detail shell', () => {
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('A static snippet mock.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-viewport-zoom').textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('should open the input field panel and editor', () => {
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /snippet\.inputFieldButton/i })[0])
|
||||
expect(screen.getAllByText('snippet.panelTitle').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /datasetPipeline\.inputFieldPanel\.addInputField/i })[0])
|
||||
expect(screen.getAllByText('datasetPipeline.inputFieldPanel.addInputField').length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should toggle the publish menu', () => {
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
|
||||
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading fallback when snippet data is unavailable', () => {
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<SnippetPage snippetId="missing-snippet" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCreateCard from '../snippet-create-card'
|
||||
|
||||
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockCreateMutate: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: mockCreateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../snippet-import-dsl-dialog', () => ({
|
||||
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: (snippetId: string) => void }) => {
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="snippet-import-dsl-dialog">
|
||||
<button type="button" onClick={() => onSuccess?.('snippet-imported')}>Complete Import</button>
|
||||
<button type="button" onClick={onClose}>Close Import</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SnippetCreateCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Create From Blank', () => {
|
||||
it('should open the create dialog and create a snippet from the modal', async () => {
|
||||
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
|
||||
options?.onSuccess?.({ id: 'snippet-123' })
|
||||
})
|
||||
|
||||
render(<SnippetCreateCard />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
|
||||
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'My Snippet' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
|
||||
target: { value: 'Useful snippet description' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
|
||||
|
||||
expect(mockCreateMutate).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'My Snippet',
|
||||
description: 'Useful snippet description',
|
||||
icon_info: {
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
})
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import DSL', () => {
|
||||
it('should open the import dialog and navigate when the import succeeds', async () => {
|
||||
render(<SnippetCreateCard />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.importDSL' }))
|
||||
expect(screen.getByTestId('snippet-import-dsl-dialog')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Complete Import' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
|
||||
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
|
||||
|
||||
type SnippetInputFieldEditorProps = {
|
||||
field?: SnippetInputField | null
|
||||
onClose: () => void
|
||||
onSubmit: (field: SnippetInputField) => void
|
||||
}
|
||||
|
||||
const SnippetInputFieldEditor = ({
|
||||
field,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: SnippetInputFieldEditorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
return convertToInputFieldFormData(field || undefined)
|
||||
}, [field])
|
||||
|
||||
const handleSubmit = useCallback((value: FormData) => {
|
||||
onSubmit(convertFormDataToINputField(value))
|
||||
}, [onSubmit])
|
||||
|
||||
return (
|
||||
<div className="relative mr-1 flex h-fit max-h-full w-[min(400px,calc(100vw-24px))] flex-col overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
|
||||
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
|
||||
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
<InputFieldForm
|
||||
initialData={initialData}
|
||||
supportFile
|
||||
onCancel={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
isEditMode={!!field}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetInputFieldEditor
|
||||
119
web/app/components/snippets/components/panel/index.tsx
Normal file
119
web/app/components/snippets/components/panel/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
|
||||
|
||||
type SnippetInputFieldPanelProps = {
|
||||
fields: SnippetInputField[]
|
||||
onClose: () => void
|
||||
onAdd: () => void
|
||||
onEdit: (field: SnippetInputField) => void
|
||||
onRemove: (index: number) => void
|
||||
onPrimarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSecondarySortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const toInputFields = (list: SortableItem[]) => {
|
||||
return list.map((item) => {
|
||||
const { id: _id, chosen: _chosen, selected: _selected, ...field } = item
|
||||
return field
|
||||
})
|
||||
}
|
||||
|
||||
const SnippetInputFieldPanel = ({
|
||||
fields,
|
||||
onClose,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
}: SnippetInputFieldPanelProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const primaryFields = fields.slice(0, 2)
|
||||
const secondaryFields = fields.slice(2)
|
||||
|
||||
const handlePrimaryRemove = useCallback((index: number) => {
|
||||
onRemove(index)
|
||||
}, [onRemove])
|
||||
|
||||
const handleSecondaryRemove = useCallback((index: number) => {
|
||||
onRemove(index + primaryFields.length)
|
||||
}, [onRemove, primaryFields.length])
|
||||
|
||||
const handlePrimaryEdit = useCallback((id: string) => {
|
||||
const field = primaryFields.find(item => item.variable === id)
|
||||
if (field)
|
||||
onEdit(field)
|
||||
}, [onEdit, primaryFields])
|
||||
|
||||
const handleSecondaryEdit = useCallback((id: string) => {
|
||||
const field = secondaryFields.find(item => item.variable === id)
|
||||
if (field)
|
||||
onEdit(field)
|
||||
}, [onEdit, secondaryFields])
|
||||
|
||||
return (
|
||||
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-text-primary system-xl-semibold">
|
||||
{t('panelTitle')}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-sm-regular">
|
||||
{t('panelDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
|
||||
<span aria-hidden className="i-ri-add-line h-4 w-4" />
|
||||
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col overflow-y-auto">
|
||||
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('panelPrimaryGroup')}
|
||||
</div>
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 pb-2"
|
||||
inputFields={primaryFields}
|
||||
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
|
||||
onRemoveField={handlePrimaryRemove}
|
||||
onEditField={handlePrimaryEdit}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-2">
|
||||
<Divider type="horizontal" className="bg-divider-subtle" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('panelSecondaryGroup')}
|
||||
</div>
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 pb-4"
|
||||
inputFields={secondaryFields}
|
||||
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
|
||||
onRemoveField={handleSecondaryRemove}
|
||||
onEditField={handleSecondaryEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SnippetInputFieldPanel)
|
||||
29
web/app/components/snippets/components/publish-menu.tsx
Normal file
29
web/app/components/snippets/components/publish-menu.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetailUIModel } from '@/models/snippet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
const PublishMenu = ({
|
||||
uiMeta,
|
||||
}: {
|
||||
uiMeta: SnippetDetailUIModel
|
||||
}) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
|
||||
<div className="text-text-tertiary system-xs-semibold-uppercase">
|
||||
{t('publishMenuCurrentDraft')}
|
||||
</div>
|
||||
<div className="pt-1 text-text-secondary system-sm-medium">
|
||||
{uiMeta.autoSavedAt}
|
||||
</div>
|
||||
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
|
||||
{t('publishButton')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishMenu
|
||||
58
web/app/components/snippets/components/snippet-card.tsx
Normal file
58
web/app/components/snippets/components/snippet-card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type Props = {
|
||||
snippet: SnippetListItem
|
||||
}
|
||||
|
||||
const SnippetCard = ({ snippet }: Props) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
|
||||
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
|
||||
{!snippet.is_published && (
|
||||
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
|
||||
Draft
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={snippet.icon_info.icon_type}
|
||||
icon={snippet.icon_info.icon}
|
||||
background={snippet.icon_info.icon_background}
|
||||
imageUrl={snippet.icon_info.icon_url}
|
||||
/>
|
||||
<div className="w-0 grow py-[1px]">
|
||||
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
|
||||
{snippet.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
|
||||
<span className="truncate">{snippet.author}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.updated_at}</span>
|
||||
{!snippet.is_published && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="truncate">{t('usageCount', { count: snippet.use_count })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCard
|
||||
106
web/app/components/snippets/components/snippet-children.tsx
Normal file
106
web/app/components/snippets/components/snippet-children.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
|
||||
import SnippetInputFieldEditor from './input-field-editor'
|
||||
import SnippetInputFieldPanel from './panel'
|
||||
import PublishMenu from './publish-menu'
|
||||
import SnippetHeader from './snippet-header'
|
||||
import SnippetWorkflowPanel from './workflow-panel'
|
||||
|
||||
type SnippetChildrenProps = {
|
||||
fields: SnippetInputField[]
|
||||
uiMeta: SnippetDetailUIModel
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
isPublishMenuOpen: boolean
|
||||
onToggleInputPanel: () => void
|
||||
onTogglePublishMenu: () => void
|
||||
onCloseInputPanel: () => void
|
||||
onOpenEditor: (field?: SnippetInputField | null) => void
|
||||
onCloseEditor: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
onRemoveField: (index: number) => void
|
||||
onPrimarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSecondarySortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const SnippetChildren = ({
|
||||
fields,
|
||||
uiMeta,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
isPublishMenuOpen,
|
||||
onToggleInputPanel,
|
||||
onTogglePublishMenu,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
}: SnippetChildrenProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
|
||||
|
||||
<SnippetHeader
|
||||
inputFieldCount={fields.length}
|
||||
onToggleInputPanel={onToggleInputPanel}
|
||||
onTogglePublishMenu={onTogglePublishMenu}
|
||||
/>
|
||||
|
||||
<SnippetWorkflowPanel
|
||||
fields={fields}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
onCloseInputPanel={onCloseInputPanel}
|
||||
onOpenEditor={onOpenEditor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
|
||||
{isPublishMenuOpen && (
|
||||
<div className="absolute right-3 top-14 z-20">
|
||||
<PublishMenu uiMeta={uiMeta} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInputPanelOpen && (
|
||||
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
|
||||
<div className="pointer-events-auto h-full xl:hidden">
|
||||
<SnippetInputFieldPanel
|
||||
fields={fields}
|
||||
onClose={onCloseInputPanel}
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditorOpen && (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-black/10 px-3 xl:hidden">
|
||||
<div className="pointer-events-auto w-full max-w-md">
|
||||
<SnippetInputFieldEditor
|
||||
field={editingField}
|
||||
onClose={onCloseEditor}
|
||||
onSubmit={onSubmitField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetChildren
|
||||
109
web/app/components/snippets/components/snippet-create-card.tsx
Normal file
109
web/app/components/snippets/components/snippet-create-card.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
useCreateSnippetMutation,
|
||||
} from '@/service/use-snippets'
|
||||
import SnippetImportDSLDialog from './snippet-import-dsl-dialog'
|
||||
|
||||
const SnippetCreateCard = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isImportDSLDialogOpen, setIsImportDSLDialogOpen] = useState(false)
|
||||
|
||||
const handleCreateFromBlank = () => {
|
||||
setIsCreateDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleImportDSL = () => {
|
||||
setIsImportDSLDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCreateSnippet = ({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
}) => {
|
||||
createSnippetMutation.mutate({
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onSuccess: (snippet) => {
|
||||
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
|
||||
setIsCreateDialogOpen(false)
|
||||
push(`/snippets/${snippet.id}/orchestrate`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('createFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
|
||||
<div className="grow rounded-t-xl p-2">
|
||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={createSnippetMutation.isPending}
|
||||
onClick={handleCreateFromBlank}
|
||||
>
|
||||
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('createFromBlank')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={handleImportDSL}
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('importDSL', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
isSubmitting={createSnippetMutation.isPending}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isImportDSLDialogOpen && (
|
||||
<SnippetImportDSLDialog
|
||||
show={isImportDSLDialogOpen}
|
||||
onClose={() => setIsImportDSLDialogOpen(false)}
|
||||
onSuccess={(snippetId) => {
|
||||
setIsImportDSLDialogOpen(false)
|
||||
push(`/snippets/${snippetId}/orchestrate`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCreateCard
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SnippetHeaderProps = {
|
||||
inputFieldCount: number
|
||||
onToggleInputPanel: () => void
|
||||
onTogglePublishMenu: () => void
|
||||
}
|
||||
|
||||
const SnippetHeader = ({
|
||||
inputFieldCount,
|
||||
onToggleInputPanel,
|
||||
onTogglePublishMenu,
|
||||
}: SnippetHeaderProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
|
||||
onClick={onToggleInputPanel}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
|
||||
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
|
||||
{inputFieldCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
|
||||
>
|
||||
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
|
||||
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
|
||||
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
|
||||
onClick={onTogglePublishMenu}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetHeader
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import {
|
||||
useConfirmSnippetImportMutation,
|
||||
useImportSnippetDSLMutation,
|
||||
} from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
|
||||
type SnippetImportDSLDialogProps = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: (snippetId: string) => void
|
||||
}
|
||||
|
||||
const SnippetImportDSLTab = {
|
||||
FromFile: 'from-file',
|
||||
FromURL: 'from-url',
|
||||
} as const
|
||||
|
||||
type SnippetImportDSLTabValue = typeof SnippetImportDSLTab[keyof typeof SnippetImportDSLTab]
|
||||
|
||||
const SnippetImportDSLDialog = ({
|
||||
show,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: SnippetImportDSLDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
const importSnippetDSLMutation = useImportSnippetDSLMutation()
|
||||
const confirmSnippetImportMutation = useConfirmSnippetImportMutation()
|
||||
const [currentFile, setCurrentFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState<SnippetImportDSLTabValue>(SnippetImportDSLTab.FromFile)
|
||||
const [dslUrlValue, setDslUrlValue] = useState('')
|
||||
const [showVersionMismatchDialog, setShowVersionMismatchDialog] = useState(false)
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
|
||||
const isImporting = importSnippetDSLMutation.isPending || confirmSnippetImportMutation.isPending
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setCurrentFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const completeImport = (snippetId?: string, status: string = DSLImportStatus.COMPLETED) => {
|
||||
if (!snippetId) {
|
||||
toast.error(t('importFailed', { ns: 'snippet' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED_WITH_WARNINGS)
|
||||
toast.warning(t('newApp.appCreateDSLWarning', { ns: 'app' }))
|
||||
else
|
||||
toast.success(t('importSuccess', { ns: 'snippet' }))
|
||||
|
||||
onSuccess?.(snippetId)
|
||||
}
|
||||
|
||||
const handleImportResponse = (response: {
|
||||
id: string
|
||||
status: string
|
||||
snippet_id?: string
|
||||
imported_dsl_version?: string
|
||||
current_dsl_version?: string
|
||||
}) => {
|
||||
if (response.status === DSLImportStatus.COMPLETED || response.status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
completeImport(response.snippet_id, response.status)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: response.imported_dsl_version ?? '',
|
||||
systemVersion: response.current_dsl_version ?? '',
|
||||
})
|
||||
setImportId(response.id)
|
||||
setShowVersionMismatchDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(t('importFailed', { ns: 'snippet' }))
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (currentTab === SnippetImportDSLTab.FromFile && !currentFile)
|
||||
return
|
||||
if (currentTab === SnippetImportDSLTab.FromURL && !dslUrlValue)
|
||||
return
|
||||
|
||||
importSnippetDSLMutation.mutate({
|
||||
mode: currentTab === SnippetImportDSLTab.FromFile ? DSLImportMode.YAML_CONTENT : DSLImportMode.YAML_URL,
|
||||
yamlContent: currentTab === SnippetImportDSLTab.FromFile ? fileContent || '' : undefined,
|
||||
yamlUrl: currentTab === SnippetImportDSLTab.FromURL ? dslUrlValue : undefined,
|
||||
}, {
|
||||
onSuccess: handleImportResponse,
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { run: handleCreateSnippet } = useDebounceFn(handleCreate, { wait: 300 })
|
||||
|
||||
const handleConfirmImport = () => {
|
||||
if (!importId)
|
||||
return
|
||||
|
||||
confirmSnippetImportMutation.mutate({
|
||||
importId,
|
||||
}, {
|
||||
onSuccess: (response) => {
|
||||
setShowVersionMismatchDialog(false)
|
||||
completeImport(response.snippet_id)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (!show || showVersionMismatchDialog || isImporting)
|
||||
return
|
||||
|
||||
if ((currentTab === SnippetImportDSLTab.FromFile && currentFile) || (currentTab === SnippetImportDSLTab.FromURL && dslUrlValue))
|
||||
handleCreateSnippet()
|
||||
})
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (isImporting)
|
||||
return true
|
||||
if (currentTab === SnippetImportDSLTab.FromFile)
|
||||
return !currentFile
|
||||
return !dslUrlValue
|
||||
}, [currentFile, currentTab, dslUrlValue, isImporting])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={show} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="w-[520px] p-0">
|
||||
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('importFromDSL', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton className="right-5 top-6 h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
|
||||
{[
|
||||
{ key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) },
|
||||
{ key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex h-full cursor-pointer items-center',
|
||||
currentTab === tab.key && 'text-text-primary',
|
||||
)}
|
||||
onClick={() => setCurrentTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{currentTab === tab.key && (
|
||||
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{currentTab === SnippetImportDSLTab.FromFile && (
|
||||
<Uploader
|
||||
className="mt-0"
|
||||
file={currentFile}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
)}
|
||||
{currentTab === SnippetImportDSLTab.FromURL && (
|
||||
<div>
|
||||
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
|
||||
<Input
|
||||
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
|
||||
value={dslUrlValue}
|
||||
onChange={e => setDslUrlValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 py-5">
|
||||
<Button className="mr-2" disabled={isImporting} onClick={onClose}>
|
||||
{t('newApp.Cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={buttonDisabled}
|
||||
variant="primary"
|
||||
onClick={handleCreateSnippet}
|
||||
className="gap-1"
|
||||
>
|
||||
<span>{t('newApp.Create', { ns: 'app' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showVersionMismatchDialog} onOpenChange={open => !open && setShowVersionMismatchDialog(false)}>
|
||||
<DialogContent className="w-[480px]">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<div className="flex grow flex-col text-text-secondary system-md-regular">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.importedVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.systemVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
|
||||
<Button variant="secondary" disabled={isImporting} onClick={() => setShowVersionMismatchDialog(false)}>
|
||||
{t('newApp.Cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button variant="primary" destructive disabled={isImporting} onClick={handleConfirmImport}>
|
||||
{t('newApp.Confirm', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetImportDSLDialog
|
||||
218
web/app/components/snippets/components/snippet-main.tsx
Normal file
218
web/app/components/snippets/components/snippet-main.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet'
|
||||
import {
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalWindowFill,
|
||||
RiTerminalWindowLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import AppSideBar from '@/app/components/app-sidebar'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useConfigsMap } from '../hooks/use-configs-map'
|
||||
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
|
||||
import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
import SnippetChildren from './snippet-children'
|
||||
|
||||
type SnippetMainProps = {
|
||||
payload: SnippetDetailPayload
|
||||
snippetId: string
|
||||
section: SnippetSection
|
||||
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
|
||||
|
||||
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
|
||||
normal: RiTerminalWindowLine,
|
||||
selected: RiTerminalWindowFill,
|
||||
}
|
||||
|
||||
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
|
||||
normal: RiFlaskLine,
|
||||
selected: RiFlaskFill,
|
||||
}
|
||||
|
||||
const SnippetMain = ({
|
||||
payload,
|
||||
snippetId,
|
||||
section,
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
}: SnippetMainProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { graph, snippet, uiMeta } = payload
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
|
||||
const {
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft(snippetId)
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
const configsMap = useConfigsMap(snippetId)
|
||||
const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand)
|
||||
const {
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
isPublishMenuOpen,
|
||||
closeEditor,
|
||||
openEditor,
|
||||
reset,
|
||||
setInputPanelOpen,
|
||||
toggleInputPanel,
|
||||
togglePublishMenu,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
editingField: state.editingField,
|
||||
isEditorOpen: state.isEditorOpen,
|
||||
isInputPanelOpen: state.isInputPanelOpen,
|
||||
isPublishMenuOpen: state.isPublishMenuOpen,
|
||||
closeEditor: state.closeEditor,
|
||||
openEditor: state.openEditor,
|
||||
reset: state.reset,
|
||||
setInputPanelOpen: state.setInputPanelOpen,
|
||||
toggleInputPanel: state.toggleInputPanel,
|
||||
togglePublishMenu: state.togglePublishMenu,
|
||||
})))
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [reset, snippetId])
|
||||
|
||||
useEffect(() => {
|
||||
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||
const mode = isMobile ? 'collapse' : 'expand'
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
const primaryFields = useMemo(() => fields.slice(0, 2), [fields])
|
||||
const secondaryFields = useMemo(() => fields.slice(2), [fields])
|
||||
|
||||
const handlePrimarySortChange = (newFields: SnippetInputField[]) => {
|
||||
setFields([...newFields, ...secondaryFields])
|
||||
}
|
||||
|
||||
const handleSecondarySortChange = (newFields: SnippetInputField[]) => {
|
||||
setFields([...primaryFields, ...newFields])
|
||||
}
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
setFields(current => current.filter((_, currentIndex) => currentIndex !== index))
|
||||
}
|
||||
|
||||
const handleSubmitField = (field: SnippetInputField) => {
|
||||
const originalVariable = editingField?.variable
|
||||
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
|
||||
|
||||
if (duplicated) {
|
||||
toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (originalVariable)
|
||||
setFields(current => current.map(item => item.variable === originalVariable ? field : item))
|
||||
else
|
||||
setFields(current => [...current, field])
|
||||
|
||||
closeEditor()
|
||||
}
|
||||
|
||||
const handleToggleInputPanel = () => {
|
||||
if (isInputPanelOpen)
|
||||
closeEditor()
|
||||
toggleInputPanel()
|
||||
}
|
||||
|
||||
const handleCloseInputPanel = () => {
|
||||
closeEditor()
|
||||
setInputPanelOpen(false)
|
||||
}
|
||||
|
||||
const hooksStore = useMemo(() => {
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
handleRefreshWorkflowDraft,
|
||||
configsMap,
|
||||
}
|
||||
}, [configsMap, doSyncWorkflowDraft, handleRefreshWorkflowDraft, syncWorkflowDraftWhenPageClose])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full overflow-hidden bg-background-body">
|
||||
<AppSideBar
|
||||
navigation={[]}
|
||||
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
|
||||
renderNavigation={mode => (
|
||||
<>
|
||||
<NavLink
|
||||
mode={mode}
|
||||
name={t('sectionOrchestrate')}
|
||||
iconMap={ORCHESTRATE_ICONS}
|
||||
href={`/snippets/${snippetId}/orchestrate`}
|
||||
active={section === 'orchestrate'}
|
||||
/>
|
||||
<NavLink
|
||||
mode={mode}
|
||||
name={t('sectionEvaluation')}
|
||||
iconMap={EVALUATION_ICONS}
|
||||
href={`/snippets/${snippetId}/evaluation`}
|
||||
active={section === 'evaluation'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
|
||||
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
|
||||
{section === 'evaluation'
|
||||
? (
|
||||
<Evaluation resourceType="snippet" resourceId={snippetId} />
|
||||
)
|
||||
: (
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport ?? graph.viewport}
|
||||
hooksStore={hooksStore}
|
||||
>
|
||||
<SnippetChildren
|
||||
fields={fields}
|
||||
uiMeta={uiMeta}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
isPublishMenuOpen={isPublishMenuOpen}
|
||||
onToggleInputPanel={handleToggleInputPanel}
|
||||
onTogglePublishMenu={togglePublishMenu}
|
||||
onCloseInputPanel={handleCloseInputPanel}
|
||||
onOpenEditor={openEditor}
|
||||
onCloseEditor={closeEditor}
|
||||
onSubmitField={handleSubmitField}
|
||||
onRemoveField={handleRemoveField}
|
||||
onPrimarySortChange={handlePrimarySortChange}
|
||||
onSecondarySortChange={handleSecondarySortChange}
|
||||
/>
|
||||
</WorkflowWithInnerContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetMain
|
||||
111
web/app/components/snippets/components/workflow-panel.tsx
Normal file
111
web/app/components/snippets/components/workflow-panel.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { memo, useMemo } from 'react'
|
||||
import Panel from '@/app/components/workflow/panel'
|
||||
import SnippetInputFieldEditor from './input-field-editor'
|
||||
import SnippetInputFieldPanel from './panel'
|
||||
|
||||
type SnippetWorkflowPanelProps = {
|
||||
fields: SnippetInputField[]
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
onCloseInputPanel: () => void
|
||||
onOpenEditor: (field?: SnippetInputField | null) => void
|
||||
onCloseEditor: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
onRemoveField: (index: number) => void
|
||||
onPrimarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSecondarySortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const SnippetPanelOnLeft = ({
|
||||
fields,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
return (
|
||||
<div className="hidden xl:flex">
|
||||
{isEditorOpen && (
|
||||
<SnippetInputFieldEditor
|
||||
field={editingField}
|
||||
onClose={onCloseEditor}
|
||||
onSubmit={onSubmitField}
|
||||
/>
|
||||
)}
|
||||
{isInputPanelOpen && (
|
||||
<SnippetInputFieldPanel
|
||||
fields={fields}
|
||||
onClose={onCloseInputPanel}
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetWorkflowPanel = ({
|
||||
fields,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
const panelProps: PanelProps = useMemo(() => {
|
||||
return {
|
||||
components: {
|
||||
left: (
|
||||
<SnippetPanelOnLeft
|
||||
fields={fields}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
onCloseInputPanel={onCloseInputPanel}
|
||||
onOpenEditor={onOpenEditor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}
|
||||
}, [
|
||||
editingField,
|
||||
fields,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseEditor,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onPrimarySortChange,
|
||||
onRemoveField,
|
||||
onSecondarySortChange,
|
||||
onSubmitField,
|
||||
])
|
||||
|
||||
return <Panel {...panelProps} />
|
||||
}
|
||||
|
||||
export default memo(SnippetWorkflowPanel)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSnippetInit } from '../use-snippet-init'
|
||||
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
const mockSetPublishedAt = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockUseSnippetApiDetail = vi.fn()
|
||||
const mockUseSnippetDraftWorkflow = vi.fn()
|
||||
const mockUseSnippetDefaultBlockConfigs = vi.fn()
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
setState: mockWorkflowStoreSetState,
|
||||
getState: () => ({
|
||||
setDraftUpdatedAt: mockSetDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetDraftWorkflow: (snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => mockUseSnippetDraftWorkflow(snippetId, onSuccess),
|
||||
useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess),
|
||||
useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess),
|
||||
}))
|
||||
|
||||
describe('useSnippetInit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseSnippetApiDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
type: 'node',
|
||||
is_published: false,
|
||||
version: '1',
|
||||
use_count: 0,
|
||||
icon_info: {
|
||||
icon_type: null,
|
||||
icon: '🪄',
|
||||
icon_background: '#E0EAFF',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_300_000,
|
||||
author: 'Evan',
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDefaultBlockConfigs.mockReturnValue({
|
||||
data: undefined,
|
||||
})
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return snippet detail query result', () => {
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1')
|
||||
expect(result.current.data?.snippet.id).toBe('snippet-1')
|
||||
expect(result.current.data?.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 })
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should sync draft metadata into workflow store', () => {
|
||||
mockUseSnippetDraftWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => {
|
||||
onSuccess?.({
|
||||
updated_at: 1_712_345_678,
|
||||
hash: 'draft-hash',
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
|
||||
})
|
||||
|
||||
it('should normalize array default block configs into workflow store state', () => {
|
||||
mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => {
|
||||
onSuccess?.([
|
||||
{ type: 'llm', config: { model: 'gpt-4.1' } },
|
||||
{ type: 'code', config: { language: 'python3' } },
|
||||
])
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
nodesDefaultConfigs: {
|
||||
llm: { model: 'gpt-4.1' },
|
||||
code: { language: 'python3' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep object default block configs as-is', () => {
|
||||
mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => {
|
||||
onSuccess?.({
|
||||
llm: { model: 'gpt-4.1' },
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
nodesDefaultConfigs: {
|
||||
llm: { model: 'gpt-4.1' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync published created_at into workflow store', () => {
|
||||
mockUseSnippetPublishedWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { created_at: number }) => void) => {
|
||||
onSuccess?.({
|
||||
created_at: 1_712_345_678,
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
})
|
||||
|
||||
it('should stay loading while draft workflow is still fetching', () => {
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
})
|
||||
24
web/app/components/snippets/hooks/use-configs-map.ts
Normal file
24
web/app/components/snippets/hooks/use-configs-map.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
export const useConfigsMap = (snippetId: string) => {
|
||||
const fileUploadConfig = useStore(s => s.fileUploadConfig)
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
flowId: snippetId,
|
||||
flowType: FlowType.snippet,
|
||||
fileSettings: {
|
||||
image: {
|
||||
enabled: false,
|
||||
detail: Resolution.high,
|
||||
number_limits: 3,
|
||||
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
},
|
||||
fileUploadConfig,
|
||||
},
|
||||
}
|
||||
}, [fileUploadConfig, snippetId])
|
||||
}
|
||||
125
web/app/components/snippets/hooks/use-nodes-sync-draft.ts
Normal file
125
web/app/components/snippets/hooks/use-nodes-sync-draft.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { postWithKeepalive } from '@/service/fetch'
|
||||
import { useSnippetRefreshDraft } from './use-snippet-refresh-draft'
|
||||
|
||||
const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => {
|
||||
return !!error
|
||||
&& typeof error === 'object'
|
||||
&& 'bodyUsed' in error
|
||||
&& 'json' in error
|
||||
&& typeof error.json === 'function'
|
||||
}
|
||||
|
||||
export const useNodesSyncDraft = (snippetId: string) => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
|
||||
const getPostParams = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
transform,
|
||||
} = store.getState()
|
||||
const nodes = getNodes().filter(node => !node.data?._isTempNode)
|
||||
const [x, y, zoom] = transform
|
||||
const { syncWorkflowDraftHash } = workflowStore.getState()
|
||||
|
||||
if (!snippetId)
|
||||
return null
|
||||
|
||||
const producedNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
Object.keys(node.data).forEach((key) => {
|
||||
if (key.startsWith('_'))
|
||||
delete node.data[key]
|
||||
})
|
||||
})
|
||||
})
|
||||
const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
Object.keys(edge.data).forEach((key) => {
|
||||
if (key.startsWith('_'))
|
||||
delete edge.data[key]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
url: `/snippets/${snippetId}/workflows/draft`,
|
||||
params: {
|
||||
graph: {
|
||||
nodes: producedNodes,
|
||||
edges: producedEdges,
|
||||
viewport: { x, y, zoom },
|
||||
},
|
||||
hash: syncWorkflowDraftHash,
|
||||
},
|
||||
}
|
||||
}, [snippetId, store, workflowStore])
|
||||
|
||||
const syncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const postParams = getPostParams()
|
||||
if (postParams)
|
||||
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
|
||||
}, [getNodesReadOnly, getPostParams])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncDraftCallback,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const postParams = getPostParams()
|
||||
if (!postParams)
|
||||
return
|
||||
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
try {
|
||||
const response = await consoleClient.snippets.syncDraftWorkflow({
|
||||
params: { snippetId },
|
||||
body: postParams.params,
|
||||
})
|
||||
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
callback?.onSuccess?.()
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (isSyncConflictError(error) && !error.bodyUsed) {
|
||||
error.json().then((err) => {
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
|
||||
handleRefreshWorkflowDraft()
|
||||
})
|
||||
}
|
||||
callback?.onError?.()
|
||||
}
|
||||
finally {
|
||||
callback?.onSettled?.()
|
||||
}
|
||||
}, [getNodesReadOnly, getPostParams, handleRefreshWorkflowDraft, snippetId, workflowStore])
|
||||
|
||||
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
|
||||
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
}
|
||||
}
|
||||
82
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
82
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useSnippetDefaultBlockConfigs,
|
||||
useSnippetDraftWorkflow,
|
||||
useSnippetPublishedWorkflow,
|
||||
} from '@/service/use-snippet-workflows'
|
||||
import {
|
||||
buildSnippetDetailPayload,
|
||||
useSnippetApiDetail,
|
||||
} from '@/service/use-snippets'
|
||||
import { getSnippetDetailMock } from '@/service/use-snippets.mock'
|
||||
|
||||
const normalizeNodesDefaultConfigs = (nodesDefaultConfigs: unknown) => {
|
||||
if (!nodesDefaultConfigs || typeof nodesDefaultConfigs !== 'object')
|
||||
return {}
|
||||
|
||||
if (!Array.isArray(nodesDefaultConfigs))
|
||||
return nodesDefaultConfigs as Record<string, unknown>
|
||||
|
||||
return nodesDefaultConfigs.reduce((acc, item) => {
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& 'type' in item
|
||||
&& 'config' in item
|
||||
&& typeof item.type === 'string'
|
||||
) {
|
||||
acc[item.type] = item.config
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
const isNotFoundError = (error: unknown) => {
|
||||
return !!error && typeof error === 'object' && 'status' in error && error.status === 404
|
||||
}
|
||||
|
||||
export const useSnippetInit = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const snippetApiDetail = useSnippetApiDetail(snippetId)
|
||||
const draftWorkflowQuery = useSnippetDraftWorkflow(snippetId, (draftWorkflow) => {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
setDraftUpdatedAt(draftWorkflow.updated_at)
|
||||
setSyncWorkflowDraftHash(draftWorkflow.hash)
|
||||
})
|
||||
useSnippetDefaultBlockConfigs(snippetId, (nodesDefaultConfigs) => {
|
||||
workflowStore.setState({
|
||||
nodesDefaultConfigs: normalizeNodesDefaultConfigs(nodesDefaultConfigs),
|
||||
})
|
||||
})
|
||||
useSnippetPublishedWorkflow(snippetId, (publishedWorkflow) => {
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
})
|
||||
|
||||
const mockData = useMemo(() => getSnippetDetailMock(snippetId), [snippetId])
|
||||
const shouldUseMockData = !snippetApiDetail.isLoading && !snippetApiDetail.data && !!mockData
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (snippetApiDetail.data && !draftWorkflowQuery.isLoading)
|
||||
return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflowQuery.data)
|
||||
|
||||
if (shouldUseMockData)
|
||||
return mockData
|
||||
|
||||
if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error))
|
||||
return null
|
||||
|
||||
return undefined
|
||||
}, [draftWorkflowQuery.data, draftWorkflowQuery.isLoading, mockData, shouldUseMockData, snippetApiDetail.data, snippetApiDetail.error])
|
||||
|
||||
return {
|
||||
...snippetApiDetail,
|
||||
data,
|
||||
isLoading: shouldUseMockData ? false : snippetApiDetail.isLoading || draftWorkflowQuery.isLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { consoleClient } from '@/service/client'
|
||||
|
||||
export const useSnippetRefreshDraft = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const handleRefreshWorkflowDraft = useCallback(() => {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setIsSyncingWorkflowDraft,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (!snippetId)
|
||||
return
|
||||
|
||||
setIsSyncingWorkflowDraft(true)
|
||||
consoleClient.snippets.draftWorkflow({
|
||||
params: { snippetId },
|
||||
}).then((response) => {
|
||||
handleUpdateWorkflowCanvas({
|
||||
...response.graph,
|
||||
nodes: response.graph?.nodes || [],
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
} as WorkflowDataUpdater)
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
}).finally(() => {
|
||||
setIsSyncingWorkflowDraft(false)
|
||||
})
|
||||
}, [handleUpdateWorkflowCanvas, snippetId, workflowStore])
|
||||
|
||||
return {
|
||||
handleRefreshWorkflowDraft,
|
||||
}
|
||||
}
|
||||
72
web/app/components/snippets/index.tsx
Normal file
72
web/app/components/snippets/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetSection } from '@/models/snippet'
|
||||
import { useMemo } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import WorkflowWithDefaultContext from '@/app/components/workflow'
|
||||
import { WorkflowContextProvider } from '@/app/components/workflow/context'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import SnippetMain from './components/snippet-main'
|
||||
import { useSnippetInit } from './hooks/use-snippet-init'
|
||||
|
||||
type SnippetPageProps = {
|
||||
snippetId: string
|
||||
section?: SnippetSection
|
||||
}
|
||||
|
||||
const SnippetPage = ({
|
||||
snippetId,
|
||||
section = 'orchestrate',
|
||||
}: SnippetPageProps) => {
|
||||
const { data, isLoading } = useSnippetInit(snippetId)
|
||||
const nodesData = useMemo(() => {
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return initialNodes(data.graph.nodes, data.graph.edges)
|
||||
}, [data])
|
||||
const edgesData = useMemo(() => {
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return initialEdges(data.graph.edges, data.graph.nodes)
|
||||
}, [data])
|
||||
|
||||
if (!data || isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowWithDefaultContext
|
||||
edges={edgesData}
|
||||
nodes={nodesData}
|
||||
>
|
||||
<SnippetMain
|
||||
key={snippetId}
|
||||
snippetId={snippetId}
|
||||
section={section}
|
||||
payload={data}
|
||||
nodes={nodesData}
|
||||
edges={edgesData}
|
||||
viewport={data.graph.viewport}
|
||||
/>
|
||||
</WorkflowWithDefaultContext>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetPageWrapper = (props: SnippetPageProps) => {
|
||||
return (
|
||||
<WorkflowContextProvider>
|
||||
<SnippetPage {...props} />
|
||||
</WorkflowContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetPageWrapper
|
||||
44
web/app/components/snippets/store/index.ts
Normal file
44
web/app/components/snippets/store/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetInputField, SnippetSection } from '@/models/snippet'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type SnippetDetailUIState = {
|
||||
activeSection: SnippetSection
|
||||
isInputPanelOpen: boolean
|
||||
isPublishMenuOpen: boolean
|
||||
isPreviewMode: boolean
|
||||
isEditorOpen: boolean
|
||||
editingField: SnippetInputField | null
|
||||
setActiveSection: (section: SnippetSection) => void
|
||||
setInputPanelOpen: (value: boolean) => void
|
||||
toggleInputPanel: () => void
|
||||
setPublishMenuOpen: (value: boolean) => void
|
||||
togglePublishMenu: () => void
|
||||
setPreviewMode: (value: boolean) => void
|
||||
openEditor: (field?: SnippetInputField | null) => void
|
||||
closeEditor: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
activeSection: 'orchestrate' as SnippetSection,
|
||||
isInputPanelOpen: false,
|
||||
isPublishMenuOpen: false,
|
||||
isPreviewMode: false,
|
||||
editingField: null,
|
||||
isEditorOpen: false,
|
||||
}
|
||||
|
||||
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
|
||||
...initialState,
|
||||
setActiveSection: activeSection => set({ activeSection }),
|
||||
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
|
||||
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
|
||||
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),
|
||||
togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })),
|
||||
setPreviewMode: isPreviewMode => set({ isPreviewMode }),
|
||||
openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }),
|
||||
closeEditor: () => set({ editingField: null, isEditorOpen: false }),
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
@@ -11,20 +11,53 @@ let latestNodes: Node[] = []
|
||||
let latestHistoryEvent: string | undefined
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
const mockHandleNodesCopy = vi.fn()
|
||||
const mockHandleNodesDuplicate = vi.fn()
|
||||
const mockHandleNodesDelete = vi.fn()
|
||||
const mockHandleNodesDuplicate = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockCreateSnippetMutateAsync = vi.fn()
|
||||
const mockSyncDraftWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutateAsync: mockCreateSnippetMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
snippets: {
|
||||
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCopy: mockHandleNodesCopy,
|
||||
handleNodesDuplicate: mockHandleNodesDuplicate,
|
||||
handleNodesDelete: mockHandleNodesDelete,
|
||||
handleNodesDuplicate: mockHandleNodesDuplicate,
|
||||
}),
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -82,8 +115,13 @@ describe('SelectionContextmenu', () => {
|
||||
mockGetNodesReadOnly.mockReset()
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockHandleNodesCopy.mockReset()
|
||||
mockHandleNodesDuplicate.mockReset()
|
||||
mockHandleNodesDelete.mockReset()
|
||||
mockHandleNodesDuplicate.mockReset()
|
||||
mockPush.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
mockCreateSnippetMutateAsync.mockReset()
|
||||
mockSyncDraftWorkflow.mockReset()
|
||||
})
|
||||
|
||||
it('should not render when selectionMenu is absent', () => {
|
||||
@@ -98,6 +136,19 @@ describe('SelectionContextmenu', () => {
|
||||
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
||||
]
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
const container = document.querySelector('#workflow-container') as HTMLDivElement
|
||||
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 16,
|
||||
y: 24,
|
||||
left: 16,
|
||||
top: 24,
|
||||
right: 816,
|
||||
bottom: 624,
|
||||
width: 800,
|
||||
height: 600,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 780, clientY: 590 } })
|
||||
@@ -196,6 +247,107 @@ describe('SelectionContextmenu', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render selection actions and delegate copy, duplicate, and delete', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
||||
]
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('selection-contextmenu-item-copy')).toHaveTextContent('workflow.common.copy')
|
||||
expect(screen.getByTestId('selection-contextmenu-item-duplicate')).toHaveTextContent('workflow.common.duplicate')
|
||||
expect(screen.getByTestId('selection-contextmenu-item-delete')).toHaveTextContent('common.operation.delete')
|
||||
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-copy'))
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-duplicate'))
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-delete'))
|
||||
|
||||
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should create a snippet with the selected graph and redirect to the snippet detail page', async () => {
|
||||
mockCreateSnippetMutateAsync.mockResolvedValue({ id: 'snippet-123' })
|
||||
mockSyncDraftWorkflow.mockResolvedValue({ result: 'success' })
|
||||
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, position: { x: 120, y: 60 }, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 260, y: 120 }, width: 60, height: 30 }),
|
||||
createNode({ id: 'n3', selected: false, position: { x: 500, y: 300 }, width: 40, height: 20 }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ id: 'e1', source: 'n1', target: 'n2' }),
|
||||
createEdge({ id: 'e2', source: 'n2', target: 'n3' }),
|
||||
]
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes, edges })
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-createSnippet'))
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'My snippet' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSnippetMutateAsync).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
name: 'My snippet',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-123' },
|
||||
body: {
|
||||
graph: {
|
||||
nodes: [
|
||||
expect.objectContaining({
|
||||
id: 'n1',
|
||||
position: { x: 0, y: 0 },
|
||||
selected: false,
|
||||
data: expect.objectContaining({ selected: false }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'n2',
|
||||
position: { x: 140, y: 60 },
|
||||
selected: false,
|
||||
data: expect.objectContaining({ selected: false }),
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
expect.objectContaining({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
selected: false,
|
||||
}),
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
})
|
||||
|
||||
it('should distribute selected nodes horizontally', async () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }),
|
||||
|
||||
@@ -71,6 +71,10 @@ export const useTabs = ({
|
||||
name: t('tabs.start', { ns: 'workflow' }),
|
||||
show: shouldShowStartTab,
|
||||
disabled: shouldDisableStartTab,
|
||||
}, {
|
||||
key: TabsEnum.Snippets,
|
||||
name: t('tabs.snippets', { ns: 'workflow' }),
|
||||
show: true,
|
||||
}]
|
||||
|
||||
return tabConfigs.filter(tab => tab.show)
|
||||
@@ -100,6 +104,7 @@ export const useTabs = ({
|
||||
preferredOrder.push(TabsEnum.Sources)
|
||||
if (!noStart)
|
||||
preferredOrder.push(TabsEnum.Start)
|
||||
preferredOrder.push(TabsEnum.Snippets)
|
||||
|
||||
for (const tabKey of preferredOrder) {
|
||||
const validKey = getValidTabKey(tabKey)
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -32,6 +33,7 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import { useTabs } from './hooks'
|
||||
import Snippets from './snippets'
|
||||
import Tabs from './tabs'
|
||||
import { TabsEnum } from './types'
|
||||
|
||||
@@ -88,6 +90,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
@@ -119,28 +122,6 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
|
||||
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen)
|
||||
setSearchText('')
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
onSelect(type, pluginDefaultValue)
|
||||
}, [handleOpenChange, onSelect])
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
@@ -154,10 +135,51 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
hasUserInputNode,
|
||||
forceEnableStartTab,
|
||||
})
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen) {
|
||||
setSearchText('')
|
||||
setSnippetsLoading(false)
|
||||
}
|
||||
else if (activeTab === TabsEnum.Snippets) {
|
||||
setSnippetsLoading(true)
|
||||
}
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [activeTab, onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
onSelect(type, pluginDefaultValue)
|
||||
}, [handleOpenChange, onSelect])
|
||||
|
||||
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
}, [setActiveTab])
|
||||
if (open && newActiveTab === TabsEnum.Snippets)
|
||||
setSnippetsLoading(true)
|
||||
}, [open, setActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!snippetsLoading)
|
||||
return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSnippetsLoading(false)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [snippetsLoading])
|
||||
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start)
|
||||
@@ -171,6 +193,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
|
||||
if (activeTab === TabsEnum.Sources)
|
||||
return t('tabs.searchDataSource', { ns: 'workflow' })
|
||||
if (activeTab === TabsEnum.Snippets)
|
||||
return t('tabs.searchSnippets', { ns: 'workflow' })
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
@@ -257,6 +281,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Snippets && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
@@ -268,6 +303,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
noTools={noTools}
|
||||
onTagsChange={setTags}
|
||||
forceShowStartContent={forceShowStartContent}
|
||||
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Snippets from '../index'
|
||||
|
||||
const mockUseInfiniteSnippetList = vi.fn()
|
||||
const mockHandleInsertSnippet = vi.fn()
|
||||
const mockHandleCreateSnippet = vi.fn()
|
||||
const mockHandleOpenCreateSnippetDialog = vi.fn()
|
||||
const mockHandleCloseCreateSnippetDialog = vi.fn()
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteScroll: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useInfiniteSnippetList: (...args: unknown[]) => mockUseInfiniteSnippetList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../use-insert-snippet', () => ({
|
||||
useInsertSnippet: () => ({
|
||||
handleInsertSnippet: mockHandleInsertSnippet,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-create-snippet', () => ({
|
||||
useCreateSnippet: () => ({
|
||||
createSnippetMutation: { isPending: false },
|
||||
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
|
||||
handleCreateSnippet: mockHandleCreateSnippet,
|
||||
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
|
||||
isCreateSnippetDialogOpen: false,
|
||||
isCreatingSnippet: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../create-snippet-dialog', () => ({
|
||||
default: ({ isOpen }: { isOpen: boolean }) => isOpen ? <div data-testid="create-snippet-dialog" /> : null,
|
||||
}))
|
||||
|
||||
describe('Snippets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render loading skeleton when loading', () => {
|
||||
const { container } = render(<Snippets loading searchText="" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render empty state when snippet list is empty', () => {
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render snippet rows from infinite list data', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
|
||||
render(<Snippets searchText="customer" />)
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: 'customer',
|
||||
is_published: true,
|
||||
})
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should delegate create action from empty state', () => {
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
|
||||
|
||||
expect(mockHandleOpenCreateSnippetDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should delegate insert action when snippet item is clicked', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
fireEvent.click(screen.getByText('Customer Review'))
|
||||
|
||||
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PublishedSnippetListItem } from '../snippet-detail-card'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetDetailCard from '../snippet-detail-card'
|
||||
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
|
||||
}))
|
||||
|
||||
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetDetailCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render snippet summary information', () => {
|
||||
render(<SnippetDetailCard snippet={createSnippet()} />)
|
||||
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
expect(screen.getByText('Snippet description')).toBeInTheDocument()
|
||||
expect(screen.getByText('Evan')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unique block icons from published workflow graph', () => {
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [
|
||||
{ data: { type: 'llm' } },
|
||||
{ data: { type: 'code' } },
|
||||
{ data: { type: 'llm' } },
|
||||
{ data: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<SnippetDetailCard snippet={createSnippet()} />)
|
||||
|
||||
expect(container.querySelectorAll('[data-icon="Llm"], [data-icon="Code"]')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetEmptyState from '../snippet-empty-state'
|
||||
|
||||
describe('SnippetEmptyState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render empty state copy and create action', () => {
|
||||
const handleCreate = vi.fn()
|
||||
|
||||
render(<SnippetEmptyState onCreate={handleCreate} />)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCreate when create button is clicked', () => {
|
||||
const handleCreate = vi.fn()
|
||||
|
||||
render(<SnippetEmptyState onCreate={handleCreate} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
|
||||
|
||||
expect(handleCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PublishedSnippetListItem } from '../snippet-detail-card'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetListItem from '../snippet-list-item'
|
||||
|
||||
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetListItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render snippet name', () => {
|
||||
render(
|
||||
<SnippetListItem
|
||||
snippet={createSnippet()}
|
||||
isHovered={false}
|
||||
onMouseEnter={vi.fn()}
|
||||
onMouseLeave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Evan')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render author when hovered', () => {
|
||||
render(
|
||||
<SnippetListItem
|
||||
snippet={createSnippet()}
|
||||
isHovered
|
||||
onMouseEnter={vi.fn()}
|
||||
onMouseLeave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Evan')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should forward click and hover handlers', () => {
|
||||
const handleClick = vi.fn()
|
||||
const handleMouseEnter = vi.fn()
|
||||
const handleMouseLeave = vi.fn()
|
||||
|
||||
render(
|
||||
<SnippetListItem
|
||||
snippet={createSnippet()}
|
||||
isHovered={false}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const item = screen.getByText('Customer Review').closest('div')!
|
||||
|
||||
fireEvent.mouseEnter(item)
|
||||
fireEvent.mouseLeave(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(handleMouseLeave).toHaveBeenCalledTimes(1)
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useCreateSnippet } from '../use-create-snippet'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockSyncDraftWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
snippets: {
|
||||
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useCreateSnippet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('State', () => {
|
||||
it('should open and close create snippet dialog', () => {
|
||||
const { result } = renderHook(() => useCreateSnippet())
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenCreateSnippetDialog()
|
||||
})
|
||||
expect(result.current.isCreateSnippetDialogOpen).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseCreateSnippetDialog()
|
||||
})
|
||||
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create Flow', () => {
|
||||
it('should create snippet, sync draft workflow, and navigate on success', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ id: 'snippet-123' })
|
||||
mockSyncDraftWorkflow.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useCreateSnippet())
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenCreateSnippetDialog()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateSnippet({
|
||||
name: 'My snippet',
|
||||
description: 'desc',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'My snippet',
|
||||
description: 'desc',
|
||||
icon_info: {
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-123' },
|
||||
body: {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
|
||||
expect(result.current.isCreatingSnippet).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast when create fails', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('create failed'))
|
||||
|
||||
const { result } = renderHook(() => useCreateSnippet())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateSnippet({
|
||||
name: 'My snippet',
|
||||
description: '',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('create failed')
|
||||
expect(result.current.isCreatingSnippet).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useInsertSnippet } from '../use-insert-snippet'
|
||||
|
||||
const mockFetchQuery = vi.fn()
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockGetNodes = vi.fn()
|
||||
const mockSetNodes = vi.fn()
|
||||
const mockSetEdges = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
fetchQuery: mockFetchQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
setNodes: mockSetNodes,
|
||||
edges: [{ id: 'existing-edge', source: 'old', target: 'old-2' }],
|
||||
setEdges: mockSetEdges,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
useWorkflowHistory: () => ({
|
||||
saveStateToHistory: mockSaveStateToHistory,
|
||||
}),
|
||||
WorkflowHistoryEvent: {
|
||||
NodePaste: 'NodePaste',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useInsertSnippet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodes.mockReturnValue([
|
||||
{
|
||||
id: 'existing-node',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { selected: true },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('Insert Flow', () => {
|
||||
it('should append remapped snippet graph into current workflow graph', async () => {
|
||||
mockFetchQuery.mockResolvedValue({
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'snippet-node-1',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { selected: false, _children: [{ nodeId: 'snippet-node-2', nodeType: 'code' }] },
|
||||
},
|
||||
{
|
||||
id: 'snippet-node-2',
|
||||
parentId: 'snippet-node-1',
|
||||
position: { x: 30, y: 40 },
|
||||
data: { selected: false },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'snippet-node-1',
|
||||
sourceHandle: 'source',
|
||||
target: 'snippet-node-2',
|
||||
targetHandle: 'target',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
})
|
||||
|
||||
expect(mockFetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetEdges).toHaveBeenCalledTimes(1)
|
||||
|
||||
const nextNodes = mockSetNodes.mock.calls[0][0]
|
||||
expect(nextNodes[0].selected).toBe(false)
|
||||
expect(nextNodes[0].data.selected).toBe(false)
|
||||
expect(nextNodes).toHaveLength(3)
|
||||
expect(nextNodes[1].id).not.toBe('snippet-node-1')
|
||||
expect(nextNodes[2].parentId).toBe(nextNodes[1].id)
|
||||
expect(nextNodes[1].data._children[0].nodeId).toBe(nextNodes[2].id)
|
||||
|
||||
const nextEdges = mockSetEdges.mock.calls[0][0]
|
||||
expect(nextEdges).toHaveLength(2)
|
||||
expect(nextEdges[1].source).toBe(nextNodes[1].id)
|
||||
expect(nextEdges[1].target).toBe(nextNodes[2].id)
|
||||
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodePaste', {
|
||||
nodeId: nextNodes[1].id,
|
||||
})
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show error toast when fetching snippet workflow fails', async () => {
|
||||
mockFetchQuery.mockRejectedValue(new Error('insert failed'))
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('insert failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
179
web/app/components/workflow/block-selector/snippets/index.tsx
Normal file
179
web/app/components/workflow/block-selector/snippets/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useInfiniteScroll } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useDeferredValue,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@/app/components/base/ui/scroll-area'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/app/components/base/ui/tooltip'
|
||||
import { useInfiniteSnippetList } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import CreateSnippetDialog from '../../create-snippet-dialog'
|
||||
import SnippetDetailCard from './snippet-detail-card'
|
||||
import SnippetEmptyState from './snippet-empty-state'
|
||||
import SnippetListItem from './snippet-list-item'
|
||||
import { useCreateSnippet } from './use-create-snippet'
|
||||
import { useInsertSnippet } from './use-insert-snippet'
|
||||
|
||||
type SnippetsProps = {
|
||||
loading?: boolean
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => {
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="p-1">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1 opacity-20',
|
||||
index === 3 && 'opacity-10',
|
||||
)}
|
||||
>
|
||||
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
|
||||
<div className="min-w-0 flex-1 px-1 py-1">
|
||||
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Snippets = ({
|
||||
loading = false,
|
||||
searchText,
|
||||
}: SnippetsProps) => {
|
||||
const {
|
||||
createSnippetMutation,
|
||||
handleCloseCreateSnippetDialog,
|
||||
handleCreateSnippet,
|
||||
handleOpenCreateSnippetDialog,
|
||||
isCreateSnippetDialogOpen,
|
||||
isCreatingSnippet,
|
||||
} = useCreateSnippet()
|
||||
const { handleInsertSnippet } = useInsertSnippet()
|
||||
const deferredSearchText = useDeferredValue(searchText)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
|
||||
|
||||
const keyword = deferredSearchText.trim() || undefined
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteSnippetList({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword,
|
||||
is_published: true,
|
||||
})
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
return (data?.pages ?? []).flatMap(({ data }) => data)
|
||||
}, [data?.pages])
|
||||
|
||||
const isNoMore = hasNextPage === false
|
||||
|
||||
useInfiniteScroll(
|
||||
async () => {
|
||||
if (!hasNextPage || isFetchingNextPage)
|
||||
return { list: [] }
|
||||
|
||||
await fetchNextPage()
|
||||
return { list: [] }
|
||||
},
|
||||
{
|
||||
target: viewportRef,
|
||||
isNoMore: () => isNoMore,
|
||||
reloadDeps: [isNoMore, isFetchingNextPage, keyword],
|
||||
},
|
||||
)
|
||||
|
||||
if (loading || isLoading || (isFetching && snippets.length === 0))
|
||||
return <LoadingSkeleton />
|
||||
|
||||
return (
|
||||
<>
|
||||
{!snippets.length
|
||||
? (
|
||||
<SnippetEmptyState onCreate={handleOpenCreateSnippetDialog} />
|
||||
)
|
||||
: (
|
||||
<ScrollAreaRoot className="relative max-h-[480px] max-w-[500px] overflow-hidden">
|
||||
<ScrollAreaViewport ref={viewportRef}>
|
||||
<ScrollAreaContent className="p-1">
|
||||
{snippets.map((item) => {
|
||||
const row = (
|
||||
<SnippetListItem
|
||||
snippet={item}
|
||||
isHovered={hoveredSnippetId === item.id}
|
||||
onClick={() => handleInsertSnippet(item.id)}
|
||||
onMouseEnter={() => setHoveredSnippetId(item.id)}
|
||||
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!item.description)
|
||||
return <div key={item.id}>{row}</div>
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
render={row}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="left-start"
|
||||
variant="plain"
|
||||
popupClassName="!bg-transparent !p-0"
|
||||
>
|
||||
<SnippetDetailCard snippet={item} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)}
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateSnippetDialogOpen}
|
||||
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
|
||||
onClose={handleCloseCreateSnippetDialog}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Snippets)
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { useMemo } from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { BlockEnum } from '../../types'
|
||||
|
||||
export type PublishedSnippetListItem = SnippetListItem
|
||||
|
||||
type SnippetDetailCardProps = {
|
||||
snippet: PublishedSnippetListItem
|
||||
}
|
||||
|
||||
const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
|
||||
snippet,
|
||||
}) => {
|
||||
const { author, description, icon_info, name } = snippet
|
||||
const { data: workflow } = useSnippetPublishedWorkflow(snippet.id)
|
||||
|
||||
const blockTypes = useMemo(() => {
|
||||
const graph = workflow?.graph
|
||||
if (!graph || typeof graph !== 'object')
|
||||
return []
|
||||
|
||||
const graphRecord = graph as Record<string, unknown>
|
||||
if (!Array.isArray(graphRecord.nodes))
|
||||
return []
|
||||
|
||||
const availableBlockTypes = new Set(Object.values(BlockEnum))
|
||||
|
||||
return graphRecord.nodes.reduce<BlockEnum[]>((result, node) => {
|
||||
if (!node || typeof node !== 'object')
|
||||
return result
|
||||
|
||||
const nodeRecord = node as Record<string, unknown>
|
||||
if (!nodeRecord.data || typeof nodeRecord.data !== 'object')
|
||||
return result
|
||||
|
||||
const dataRecord = nodeRecord.data as Record<string, unknown>
|
||||
const blockType = dataRecord.type
|
||||
if (typeof blockType !== 'string' || !availableBlockTypes.has(blockType as BlockEnum))
|
||||
return result
|
||||
|
||||
const normalizedBlockType = blockType as BlockEnum
|
||||
if (!result.includes(normalizedBlockType))
|
||||
result.push(normalizedBlockType)
|
||||
|
||||
return result
|
||||
}, [])
|
||||
}, [workflow?.graph])
|
||||
|
||||
return (
|
||||
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<AppIcon
|
||||
size="tiny"
|
||||
iconType={icon_info.icon_type}
|
||||
icon={icon_info.icon}
|
||||
background={icon_info.icon_background}
|
||||
imageUrl={icon_info.icon_url}
|
||||
/>
|
||||
<div className="text-text-primary system-md-medium">{name}</div>
|
||||
</div>
|
||||
{!!description && (
|
||||
<div className="w-[200px] text-text-secondary system-xs-regular">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{!!blockTypes.length && (
|
||||
<div className="flex items-center gap-0.5 pt-1">
|
||||
{blockTypes.map(blockType => (
|
||||
<BlockIcon
|
||||
key={blockType}
|
||||
type={blockType}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!author && (
|
||||
<div className="pt-3 text-text-tertiary system-xs-regular">
|
||||
{author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetDetailCard
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type SnippetEmptyStateProps = {
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
const SnippetEmptyState: FC<SnippetEmptyStateProps> = ({
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
|
||||
<span className="i-custom-vender-line-others-search-menu h-8 w-8 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-regular">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('tabs.createSnippet', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetEmptyState
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
ComponentPropsWithoutRef,
|
||||
Ref,
|
||||
} from 'react'
|
||||
import type { PublishedSnippetListItem } from './snippet-detail-card'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SnippetListItemProps = {
|
||||
isHovered: boolean
|
||||
ref?: Ref<HTMLDivElement>
|
||||
snippet: PublishedSnippetListItem
|
||||
} & ComponentPropsWithoutRef<'div'>
|
||||
|
||||
const SnippetListItem = ({
|
||||
isHovered,
|
||||
snippet,
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: SnippetListItemProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3',
|
||||
isHovered && 'bg-background-default-hover',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<AppIcon
|
||||
size="tiny"
|
||||
iconType={snippet.icon_info.icon_type}
|
||||
icon={snippet.icon_info.icon}
|
||||
background={snippet.icon_info.icon_background}
|
||||
imageUrl={snippet.icon_info.icon_url}
|
||||
/>
|
||||
<div className="min-w-0 text-text-secondary system-sm-medium">
|
||||
{snippet.name}
|
||||
</div>
|
||||
{isHovered && snippet.author && (
|
||||
<div className="ml-auto text-text-tertiary system-xs-regular">
|
||||
{snippet.author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetListItem
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { useCreateSnippetMutation } from '@/service/use-snippets'
|
||||
|
||||
export const useCreateSnippet = () => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
|
||||
const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
|
||||
|
||||
const handleOpenCreateSnippetDialog = () => {
|
||||
setIsCreateSnippetDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseCreateSnippetDialog = () => {
|
||||
setIsCreateSnippetDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleCreateSnippet = async ({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
graph,
|
||||
}: CreateSnippetDialogPayload) => {
|
||||
setIsCreatingSnippet(true)
|
||||
|
||||
try {
|
||||
const snippet = await createSnippetMutation.mutateAsync({
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await consoleClient.snippets.syncDraftWorkflow({
|
||||
params: { snippetId: snippet.id },
|
||||
body: { graph },
|
||||
})
|
||||
|
||||
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
|
||||
handleCloseCreateSnippetDialog()
|
||||
push(`/snippets/${snippet.id}/orchestrate`)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
|
||||
}
|
||||
finally {
|
||||
setIsCreatingSnippet(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createSnippetMutation,
|
||||
handleCloseCreateSnippetDialog,
|
||||
handleCreateSnippet,
|
||||
handleOpenCreateSnippetDialog,
|
||||
isCreateSnippetDialogOpen,
|
||||
isCreatingSnippet,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Edge, Node } from '../../types'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks'
|
||||
|
||||
const getSnippetGraph = (graph: Record<string, unknown> | undefined) => {
|
||||
if (!graph)
|
||||
return { nodes: [] as Node[], edges: [] as Edge[] }
|
||||
|
||||
return {
|
||||
nodes: Array.isArray(graph.nodes) ? graph.nodes as Node[] : [],
|
||||
edges: Array.isArray(graph.edges) ? graph.edges as Edge[] : [],
|
||||
}
|
||||
}
|
||||
|
||||
const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEdges: Edge[]) => {
|
||||
const existingIds = new Set(currentNodes.map(node => node.id))
|
||||
const idMapping = new Map<string, string>()
|
||||
const rootNodes = snippetNodes.filter(node => !node.parentId)
|
||||
const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0
|
||||
const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0
|
||||
const currentMaxX = currentNodes.length
|
||||
? Math.max(...currentNodes.map((node) => {
|
||||
const nodeX = node.positionAbsolute?.x ?? node.position.x
|
||||
return nodeX + (node.width ?? 0)
|
||||
}))
|
||||
: 0
|
||||
const currentMinY = currentNodes.length
|
||||
? Math.min(...currentNodes.map(node => node.positionAbsolute?.y ?? node.position.y))
|
||||
: 0
|
||||
const offsetX = (currentNodes.length ? currentMaxX + 80 : 80) - minRootX
|
||||
const offsetY = (currentNodes.length ? currentMinY : 80) - minRootY
|
||||
|
||||
snippetNodes.forEach((node, index) => {
|
||||
let nextId = `${node.id}-${Date.now()}-${index}`
|
||||
while (existingIds.has(nextId))
|
||||
nextId = `${nextId}-1`
|
||||
existingIds.add(nextId)
|
||||
idMapping.set(node.id, nextId)
|
||||
})
|
||||
|
||||
const nodes = snippetNodes.map((node) => {
|
||||
const nextParentId = node.parentId ? idMapping.get(node.parentId) : undefined
|
||||
const isRootNode = !node.parentId
|
||||
|
||||
return {
|
||||
...node,
|
||||
id: idMapping.get(node.id)!,
|
||||
parentId: nextParentId,
|
||||
position: isRootNode
|
||||
? {
|
||||
x: node.position.x + offsetX,
|
||||
y: node.position.y + offsetY,
|
||||
}
|
||||
: node.position,
|
||||
positionAbsolute: node.positionAbsolute
|
||||
? (isRootNode
|
||||
? {
|
||||
x: node.positionAbsolute.x + offsetX,
|
||||
y: node.positionAbsolute.y + offsetY,
|
||||
}
|
||||
: node.positionAbsolute)
|
||||
: undefined,
|
||||
selected: true,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: true,
|
||||
_children: node.data._children?.map(child => ({
|
||||
...child,
|
||||
nodeId: idMapping.get(child.nodeId) ?? child.nodeId,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const edges = snippetEdges.map(edge => ({
|
||||
...edge,
|
||||
id: `${idMapping.get(edge.source)}-${edge.sourceHandle}-${idMapping.get(edge.target)}-${edge.targetHandle}`,
|
||||
source: idMapping.get(edge.source)!,
|
||||
target: idMapping.get(edge.target)!,
|
||||
selected: false,
|
||||
data: edge.data
|
||||
? {
|
||||
...edge.data,
|
||||
_connectedNodeIsSelected: true,
|
||||
}
|
||||
: edge.data,
|
||||
}))
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export const useInsertSnippet = () => {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const store = useStoreApi()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
const handleInsertSnippet = useCallback(async (snippetId: string) => {
|
||||
try {
|
||||
const workflow = await queryClient.fetchQuery(consoleQuery.snippets.publishedWorkflow.queryOptions({
|
||||
input: {
|
||||
params: { snippetId },
|
||||
},
|
||||
}))
|
||||
const { nodes: snippetNodes, edges: snippetEdges } = getSnippetGraph(workflow.graph)
|
||||
|
||||
if (!snippetNodes.length)
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const currentNodes = getNodes()
|
||||
const remappedGraph = remapSnippetGraph(currentNodes, snippetNodes, snippetEdges)
|
||||
const clearedNodes = currentNodes.map(node => ({
|
||||
...node,
|
||||
selected: false,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: false,
|
||||
},
|
||||
}))
|
||||
|
||||
setNodes([...clearedNodes, ...remappedGraph.nodes])
|
||||
setEdges([...edges, ...remappedGraph.edges])
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodePaste, {
|
||||
nodeId: remappedGraph.nodes[0]?.id,
|
||||
})
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
|
||||
}
|
||||
}, [handleSyncWorkflowDraft, queryClient, saveStateToHistory, store, t])
|
||||
|
||||
return {
|
||||
handleInsertSnippet,
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export type TabsProps = {
|
||||
noTools?: boolean
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
snippetsElem?: React.ReactNode
|
||||
}
|
||||
|
||||
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
|
||||
@@ -173,6 +174,7 @@ const Tabs: FC<TabsProps> = ({
|
||||
noTools,
|
||||
forceShowStartContent = false,
|
||||
allowStartNodeSelection = false,
|
||||
snippetsElem,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
@@ -289,6 +291,13 @@ const Tabs: FC<TabsProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeTab === TabsEnum.Snippets && snippetsElem && (
|
||||
<div className="border-t border-divider-subtle">
|
||||
{snippetsElem}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user