mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:36:31 +08:00
Merge remote-tracking branch 'origin/main' into feat/modal-dialog-ui
This commit is contained in:
commit
97e99eb986
@ -19,7 +19,7 @@
|
||||
"name": "Website Generator"
|
||||
},
|
||||
"app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041",
|
||||
"category": "Programming",
|
||||
"categories": ["Programming"],
|
||||
"copyright": null,
|
||||
"description": null,
|
||||
"is_listed": true,
|
||||
@ -35,7 +35,7 @@
|
||||
"name": "Investment Analysis Report Copilot"
|
||||
},
|
||||
"app_id": "a23b57fa-85da-49c0-a571-3aff375976c1",
|
||||
"category": "Agent",
|
||||
"categories": ["Agent"],
|
||||
"copyright": "Dify.AI",
|
||||
"description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n",
|
||||
"is_listed": true,
|
||||
@ -51,7 +51,7 @@
|
||||
"name": "Workflow Planning Assistant "
|
||||
},
|
||||
"app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ",
|
||||
"is_listed": true,
|
||||
@ -67,7 +67,7 @@
|
||||
"name": "Automated Email Reply "
|
||||
},
|
||||
"app_id": "e9d92058-7d20-4904-892f-75d90bef7587",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ",
|
||||
"is_listed": true,
|
||||
@ -83,7 +83,7 @@
|
||||
"name": "Book Translation "
|
||||
},
|
||||
"app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ",
|
||||
"is_listed": true,
|
||||
@ -99,7 +99,7 @@
|
||||
"name": "Python bug fixer"
|
||||
},
|
||||
"app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e",
|
||||
"category": "Programming",
|
||||
"categories": ["Programming"],
|
||||
"copyright": null,
|
||||
"description": null,
|
||||
"is_listed": true,
|
||||
@ -115,7 +115,7 @@
|
||||
"name": "Code Interpreter"
|
||||
},
|
||||
"app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f",
|
||||
"category": "Programming",
|
||||
"categories": ["Programming"],
|
||||
"copyright": "Copyright 2023 Dify",
|
||||
"description": "Code interpreter, clarifying the syntax and semantics of the code.",
|
||||
"is_listed": true,
|
||||
@ -131,7 +131,7 @@
|
||||
"name": "SVG Logo Design "
|
||||
},
|
||||
"app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca",
|
||||
"category": "Agent",
|
||||
"categories": ["Agent"],
|
||||
"copyright": "Dify.AI",
|
||||
"description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALL·E 3. ",
|
||||
"is_listed": true,
|
||||
@ -147,7 +147,7 @@
|
||||
"name": "Long Story Generator (Iteration) "
|
||||
},
|
||||
"app_id": "5efb98d7-176b-419c-b6ef-50767391ab62",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ",
|
||||
"is_listed": true,
|
||||
@ -163,7 +163,7 @@
|
||||
"name": "Text Summarization Workflow"
|
||||
},
|
||||
"app_id": "f00c4531-6551-45ee-808f-1d7903099515",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
|
||||
"is_listed": true,
|
||||
@ -179,7 +179,7 @@
|
||||
"name": "YouTube Channel Data Analysis"
|
||||
},
|
||||
"app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638",
|
||||
"category": "Agent",
|
||||
"categories": ["Agent"],
|
||||
"copyright": "Dify.AI",
|
||||
"description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ",
|
||||
"is_listed": true,
|
||||
@ -195,7 +195,7 @@
|
||||
"name": "Article Grading Bot"
|
||||
},
|
||||
"app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f",
|
||||
"category": "Writing",
|
||||
"categories": ["Writing"],
|
||||
"copyright": null,
|
||||
"description": "Assess the quality of articles and text based on user defined criteria. ",
|
||||
"is_listed": true,
|
||||
@ -211,7 +211,7 @@
|
||||
"name": "SEO Blog Generator"
|
||||
},
|
||||
"app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.",
|
||||
"is_listed": true,
|
||||
@ -227,7 +227,7 @@
|
||||
"name": "SQL Creator"
|
||||
},
|
||||
"app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744",
|
||||
"category": "Programming",
|
||||
"categories": ["Programming"],
|
||||
"copyright": "Copyright 2023 Dify",
|
||||
"description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.",
|
||||
"is_listed": true,
|
||||
@ -243,7 +243,7 @@
|
||||
"name": "Sentiment Analysis "
|
||||
},
|
||||
"app_id": "f06bf86b-d50c-4895-a942-35112dbe4189",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.",
|
||||
"is_listed": true,
|
||||
@ -259,7 +259,7 @@
|
||||
"name": "Strategic Consulting Expert"
|
||||
},
|
||||
"app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2",
|
||||
"category": "Assistant",
|
||||
"categories": ["Assistant"],
|
||||
"copyright": "Copyright 2023 Dify",
|
||||
"description": "I can answer your questions related to strategic marketing.",
|
||||
"is_listed": true,
|
||||
@ -275,7 +275,7 @@
|
||||
"name": "Code Converter"
|
||||
},
|
||||
"app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a",
|
||||
"category": "Programming",
|
||||
"categories": ["Programming"],
|
||||
"copyright": "Copyright 2023 Dify",
|
||||
"description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.",
|
||||
"is_listed": true,
|
||||
@ -291,7 +291,7 @@
|
||||
"name": "Question Classifier + Knowledge + Chatbot "
|
||||
},
|
||||
"app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
|
||||
"is_listed": true,
|
||||
@ -307,7 +307,7 @@
|
||||
"name": "AI Front-end interviewer"
|
||||
},
|
||||
"app_id": "127efead-8944-4e20-ba9d-12402eb345e0",
|
||||
"category": "HR",
|
||||
"categories": ["HR"],
|
||||
"copyright": "Copyright 2023 Dify",
|
||||
"description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.",
|
||||
"is_listed": true,
|
||||
@ -323,7 +323,7 @@
|
||||
"name": "Knowledge Retrieval + Chatbot "
|
||||
},
|
||||
"app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Basic Workflow Template, A chatbot with a knowledge base. ",
|
||||
"is_listed": true,
|
||||
@ -339,7 +339,7 @@
|
||||
"name": "Email Assistant Workflow "
|
||||
},
|
||||
"app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.",
|
||||
"is_listed": true,
|
||||
@ -355,7 +355,7 @@
|
||||
"name": "Customer Review Analysis Workflow "
|
||||
},
|
||||
"app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a",
|
||||
"category": "Workflow",
|
||||
"categories": ["Workflow"],
|
||||
"copyright": null,
|
||||
"description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.",
|
||||
"is_listed": true,
|
||||
|
||||
@ -52,7 +52,7 @@ class RecommendedAppResponse(ResponseModel):
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
category: str | None = None
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
position: int | None = None
|
||||
is_listed: bool | None = None
|
||||
can_trial: bool | None = None
|
||||
|
||||
@ -876,10 +876,10 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self, provider):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
payload = BuiltinProviderDefaultCredentialPayload.model_validate(console_ns.payload or {})
|
||||
return BuiltinToolManageService.set_default_provider(
|
||||
tenant_id=current_tenant_id, user_id=current_user.id, provider=provider, id=payload.id
|
||||
tenant_id=current_tenant_id, provider=provider, id=payload.id
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -842,24 +842,24 @@ class WorkflowResponseConverter:
|
||||
return []
|
||||
|
||||
files: list[Mapping[str, Any]] = []
|
||||
if isinstance(value, FileSegment):
|
||||
files.append(value.value.to_dict())
|
||||
elif isinstance(value, ArrayFileSegment):
|
||||
files.extend([i.to_dict() for i in value.value])
|
||||
elif isinstance(value, File):
|
||||
files.append(value.to_dict())
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
file = cls._get_file_var_from_value(item)
|
||||
match value:
|
||||
case FileSegment():
|
||||
files.append(value.value.to_dict())
|
||||
case ArrayFileSegment():
|
||||
files.extend([i.to_dict() for i in value.value])
|
||||
case File():
|
||||
files.append(value.to_dict())
|
||||
case list():
|
||||
for item in value:
|
||||
file = cls._get_file_var_from_value(item)
|
||||
if file:
|
||||
files.append(file)
|
||||
case dict():
|
||||
file = cls._get_file_var_from_value(value)
|
||||
if file:
|
||||
files.append(file)
|
||||
elif isinstance(
|
||||
value,
|
||||
dict,
|
||||
):
|
||||
file = cls._get_file_var_from_value(value)
|
||||
if file:
|
||||
files.append(file)
|
||||
case _:
|
||||
pass
|
||||
|
||||
return files
|
||||
|
||||
|
||||
@ -53,24 +53,27 @@ class PromptMessageUtil:
|
||||
files = []
|
||||
if isinstance(prompt_message.content, list):
|
||||
for content in prompt_message.content:
|
||||
if isinstance(content, TextPromptMessageContent):
|
||||
text += content.data
|
||||
elif isinstance(content, ImagePromptMessageContent):
|
||||
files.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"detail": content.detail.value,
|
||||
}
|
||||
)
|
||||
elif isinstance(content, AudioPromptMessageContent):
|
||||
files.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"format": content.format,
|
||||
}
|
||||
)
|
||||
match content:
|
||||
case TextPromptMessageContent():
|
||||
text += content.data
|
||||
case ImagePromptMessageContent():
|
||||
files.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"detail": content.detail.value,
|
||||
}
|
||||
)
|
||||
case AudioPromptMessageContent():
|
||||
files.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"format": content.format,
|
||||
}
|
||||
)
|
||||
case _:
|
||||
continue
|
||||
else:
|
||||
text = cast(str, prompt_message.content)
|
||||
|
||||
|
||||
@ -23,36 +23,37 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P<tool_file_id>[^/?#
|
||||
|
||||
|
||||
def safe_json_value(v):
|
||||
if isinstance(v, datetime):
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
elif isinstance(v, date):
|
||||
return v.isoformat()
|
||||
elif isinstance(v, UUID):
|
||||
return str(v)
|
||||
elif isinstance(v, Decimal):
|
||||
return float(v)
|
||||
elif isinstance(v, bytes):
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.hex()
|
||||
elif isinstance(v, memoryview):
|
||||
return v.tobytes().hex()
|
||||
elif isinstance(v, np.integer):
|
||||
return int(v)
|
||||
elif isinstance(v, np.floating):
|
||||
return float(v)
|
||||
elif isinstance(v, np.ndarray):
|
||||
return v.tolist()
|
||||
elif isinstance(v, dict):
|
||||
return safe_json_dict(v)
|
||||
elif isinstance(v, list | tuple | set):
|
||||
return [safe_json_value(i) for i in v]
|
||||
else:
|
||||
return v
|
||||
match v:
|
||||
case datetime():
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
case date():
|
||||
return v.isoformat()
|
||||
case UUID():
|
||||
return str(v)
|
||||
case Decimal():
|
||||
return float(v)
|
||||
case bytes():
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.hex()
|
||||
case memoryview():
|
||||
return v.tobytes().hex()
|
||||
case np.integer():
|
||||
return int(v)
|
||||
case np.floating():
|
||||
return float(v)
|
||||
case np.ndarray():
|
||||
return v.tolist()
|
||||
case dict():
|
||||
return safe_json_dict(v)
|
||||
case list() | tuple() | set():
|
||||
return [safe_json_value(i) for i in v]
|
||||
case _:
|
||||
return v
|
||||
|
||||
|
||||
def safe_json_dict(d: dict[str, Any]):
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
"""add recommended app categories
|
||||
|
||||
Revision ID: a4f2d8c9b731
|
||||
Revises: 227822d22895
|
||||
Create Date: 2026-04-29 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a4f2d8c9b731"
|
||||
down_revision = "227822d22895"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("categories", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
|
||||
batch_op.drop_column("categories")
|
||||
@ -878,6 +878,7 @@ class RecommendedApp(TypeBase):
|
||||
copyright: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
privacy_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
categories: Mapped[list[str] | None] = mapped_column(sa.JSON, nullable=True, default=None)
|
||||
custom_disclaimer: Mapped[str] = mapped_column(LongText, default="")
|
||||
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
|
||||
is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
|
||||
|
||||
49
api/services/recommend_app/category_order.py
Normal file
49
api/services/recommend_app/category_order.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Apply Redis-backed category ordering for DB-backed Explore apps."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
|
||||
|
||||
|
||||
def _category_order_key(language: str) -> str:
|
||||
return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}"
|
||||
|
||||
|
||||
def get_explore_app_category_order(language: str) -> list[str]:
|
||||
try:
|
||||
raw_categories = redis_client.get(_category_order_key(language))
|
||||
except Exception:
|
||||
logger.exception("Failed to read explore app category order from Redis.")
|
||||
return []
|
||||
|
||||
if not raw_categories:
|
||||
return []
|
||||
|
||||
if isinstance(raw_categories, bytes):
|
||||
raw_categories = raw_categories.decode("utf-8")
|
||||
|
||||
try:
|
||||
categories: Any = json.loads(raw_categories)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Invalid explore app category order payload for language %s.", language)
|
||||
return []
|
||||
|
||||
if not isinstance(categories, list):
|
||||
return []
|
||||
|
||||
return [category for category in categories if isinstance(category, str)]
|
||||
|
||||
|
||||
def order_categories(categories: Collection[str], language: str) -> list[str]:
|
||||
configured_order = get_explore_app_category_order(language)
|
||||
if configured_order:
|
||||
return configured_order
|
||||
|
||||
return sorted(categories)
|
||||
@ -6,6 +6,7 @@ from constants.languages import languages
|
||||
from extensions.ext_database import db
|
||||
from models.model import App, RecommendedApp
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.recommend_app.category_order import order_categories
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
@ -18,7 +19,7 @@ class RecommendedAppItemDict(TypedDict):
|
||||
copyright: Any
|
||||
privacy_policy: Any
|
||||
custom_disclaimer: str
|
||||
category: str
|
||||
categories: list[str]
|
||||
position: int
|
||||
is_listed: bool
|
||||
|
||||
@ -80,6 +81,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
if not site:
|
||||
continue
|
||||
|
||||
app_categories = recommended_app.categories or []
|
||||
recommended_app_result: RecommendedAppItemDict = {
|
||||
"id": recommended_app.id,
|
||||
"app": recommended_app.app,
|
||||
@ -88,15 +90,18 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"copyright": site.copyright,
|
||||
"privacy_policy": site.privacy_policy,
|
||||
"custom_disclaimer": site.custom_disclaimer,
|
||||
"category": recommended_app.category,
|
||||
"categories": app_categories,
|
||||
"position": recommended_app.position,
|
||||
"is_listed": recommended_app.is_listed,
|
||||
}
|
||||
recommended_apps_result.append(recommended_app_result)
|
||||
|
||||
categories.add(recommended_app.category)
|
||||
categories.update(app_categories)
|
||||
|
||||
return RecommendedAppsResultDict(recommended_apps=recommended_apps_result, categories=sorted(categories))
|
||||
return RecommendedAppsResultDict(
|
||||
recommended_apps=recommended_apps_result,
|
||||
categories=order_categories(categories, language),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None:
|
||||
|
||||
@ -408,7 +408,7 @@ class BuiltinToolManageService:
|
||||
return {"result": "success"}
|
||||
|
||||
@staticmethod
|
||||
def set_default_provider(tenant_id: str, user_id: str, provider: str, id: str):
|
||||
def set_default_provider(tenant_id: str, provider: str, id: str):
|
||||
"""
|
||||
set default provider
|
||||
"""
|
||||
@ -422,12 +422,11 @@ class BuiltinToolManageService:
|
||||
if target_provider is None:
|
||||
raise ValueError("provider not found")
|
||||
|
||||
# clear default provider
|
||||
# clear default provider (tenant-scoped: only one default per provider per workspace)
|
||||
session.execute(
|
||||
update(BuiltinToolProvider)
|
||||
.where(
|
||||
BuiltinToolProvider.tenant_id == tenant_id,
|
||||
BuiltinToolProvider.user_id == user_id,
|
||||
BuiltinToolProvider.provider == provider,
|
||||
BuiltinToolProvider.is_default.is_(True),
|
||||
)
|
||||
|
||||
@ -194,14 +194,15 @@ class VariableTruncator(BaseTruncator):
|
||||
|
||||
result: _PartResult[Any]
|
||||
# Apply type-specific truncation with target size
|
||||
if isinstance(segment, ArraySegment):
|
||||
result = self._truncate_array(segment.value, target_size)
|
||||
elif isinstance(segment, StringSegment):
|
||||
result = self._truncate_string(segment.value, target_size)
|
||||
elif isinstance(segment, ObjectSegment):
|
||||
result = self._truncate_object(segment.value, target_size)
|
||||
else:
|
||||
raise AssertionError("this should be unreachable.")
|
||||
match segment:
|
||||
case ArraySegment():
|
||||
result = self._truncate_array(segment.value, target_size)
|
||||
case StringSegment():
|
||||
result = self._truncate_string(segment.value, target_size)
|
||||
case ObjectSegment():
|
||||
result = self._truncate_object(segment.value, target_size)
|
||||
case _:
|
||||
raise AssertionError("this should be unreachable.")
|
||||
|
||||
return _PartResult(
|
||||
value=segment.model_copy(update={"value": result.value}),
|
||||
@ -219,40 +220,41 @@ class VariableTruncator(BaseTruncator):
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
if depth > _MAX_DEPTH:
|
||||
raise MaxDepthExceededError()
|
||||
if isinstance(value, str):
|
||||
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
|
||||
# However, this adds complexity as we would need to compute encoded sizes consistently
|
||||
# throughout the code. Therefore, we approximate the size using the string's length.
|
||||
# Rough estimate: number of characters, plus 2 for quotes
|
||||
return len(value) + 2
|
||||
elif isinstance(value, (int, float)):
|
||||
return len(str(value))
|
||||
elif isinstance(value, bool):
|
||||
return 4 if value else 5 # "true" or "false"
|
||||
elif value is None:
|
||||
return 4 # "null"
|
||||
elif isinstance(value, list):
|
||||
# Size = sum of elements + separators + brackets
|
||||
total = 2 # "[]"
|
||||
for i, item in enumerate(value):
|
||||
if i > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
|
||||
return total
|
||||
elif isinstance(value, dict):
|
||||
# Size = sum of keys + values + separators + brackets
|
||||
total = 2 # "{}"
|
||||
for index, key in enumerate(value.keys()):
|
||||
if index > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
|
||||
total += 1 # ":"
|
||||
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
|
||||
return total
|
||||
elif isinstance(value, File):
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
else:
|
||||
raise UnknownTypeError(f"got unknown type {type(value)}")
|
||||
match value:
|
||||
case str():
|
||||
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
|
||||
# However, this adds complexity as we would need to compute encoded sizes consistently
|
||||
# throughout the code. Therefore, we approximate the size using the string's length.
|
||||
# Rough estimate: number of characters, plus 2 for quotes
|
||||
return len(value) + 2
|
||||
case bool():
|
||||
return 4 if value else 5 # "true" or "false"
|
||||
case int() | float():
|
||||
return len(str(value))
|
||||
case None:
|
||||
return 4 # "null"
|
||||
case list():
|
||||
# Size = sum of elements + separators + brackets
|
||||
total = 2 # "[]"
|
||||
for i, item in enumerate(value):
|
||||
if i > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
|
||||
return total
|
||||
case dict():
|
||||
# Size = sum of keys + values + separators + brackets
|
||||
total = 2 # "{}"
|
||||
for index, key in enumerate(value.keys()):
|
||||
if index > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
|
||||
total += 1 # ":"
|
||||
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
|
||||
return total
|
||||
case File():
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
case _:
|
||||
raise UnknownTypeError(f"got unknown type {type(value)}")
|
||||
|
||||
def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]:
|
||||
if (size := self.calculate_json_size(value)) < target_size:
|
||||
@ -419,22 +421,23 @@ class VariableTruncator(BaseTruncator):
|
||||
target_size: int,
|
||||
) -> _PartResult[Any]:
|
||||
"""Truncate a value within an object to fit within budget."""
|
||||
if isinstance(val, UpdatedVariable):
|
||||
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
|
||||
return self._truncate_object(val.model_dump(), target_size)
|
||||
elif isinstance(val, str):
|
||||
return self._truncate_string(val, target_size)
|
||||
elif isinstance(val, list):
|
||||
return self._truncate_array(val, target_size)
|
||||
elif isinstance(val, dict):
|
||||
return self._truncate_object(val, target_size)
|
||||
elif isinstance(val, File):
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
elif val is None or isinstance(val, (bool, int, float)):
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
else:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
match val:
|
||||
case UpdatedVariable():
|
||||
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
|
||||
return self._truncate_object(val.model_dump(), target_size)
|
||||
case str():
|
||||
return self._truncate_string(val, target_size)
|
||||
case list():
|
||||
return self._truncate_array(val, target_size)
|
||||
case dict():
|
||||
return self._truncate_object(val, target_size)
|
||||
case File():
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
case None | bool() | int() | float():
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
case _:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
|
||||
|
||||
class DummyVariableTruncator(BaseTruncator):
|
||||
|
||||
@ -47,6 +47,7 @@ def _create_recommended_app(
|
||||
*,
|
||||
app_id: str,
|
||||
category: str = "chat",
|
||||
categories: list[str] | None = None,
|
||||
language: str = "en-US",
|
||||
is_listed: bool = True,
|
||||
position: int = 1,
|
||||
@ -57,6 +58,7 @@ def _create_recommended_app(
|
||||
copyright="copy",
|
||||
privacy_policy="pp",
|
||||
category=category,
|
||||
categories=[category] if categories is None else categories,
|
||||
language=language,
|
||||
is_listed=is_listed,
|
||||
position=position,
|
||||
@ -113,6 +115,53 @@ class TestFetchRecommendedAppsFromDb:
|
||||
assert "assistant" in result["categories"]
|
||||
assert "writing" in result["categories"]
|
||||
|
||||
def test_returns_multiple_categories_for_one_app(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
created_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=created_app.id)
|
||||
_create_recommended_app(
|
||||
db_session_with_containers,
|
||||
app_id=created_app.id,
|
||||
category="writing",
|
||||
categories=["writing", "assistant"],
|
||||
)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
|
||||
result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US")
|
||||
|
||||
recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id)
|
||||
assert recommended_app["categories"] == ["writing", "assistant"]
|
||||
assert "writing" in result["categories"]
|
||||
assert "assistant" in result["categories"]
|
||||
|
||||
def test_ignores_legacy_category_when_categories_are_empty(
|
||||
self,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
legacy_category = f"legacy-empty-{uuid4()}"
|
||||
tenant_id = str(uuid4())
|
||||
created_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=created_app.id)
|
||||
_create_recommended_app(
|
||||
db_session_with_containers,
|
||||
app_id=created_app.id,
|
||||
category=legacy_category,
|
||||
categories=[],
|
||||
)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
|
||||
result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US")
|
||||
|
||||
recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id)
|
||||
assert "category" not in recommended_app
|
||||
assert recommended_app["categories"] == []
|
||||
assert legacy_category not in result["categories"]
|
||||
|
||||
def test_falls_back_to_default_language_when_empty(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session
|
||||
):
|
||||
|
||||
@ -126,7 +126,7 @@ class TestRecommendedAppResponseModels:
|
||||
},
|
||||
"app_id": "app-1",
|
||||
"description": "desc",
|
||||
"category": "cat",
|
||||
"categories": ["cat", "other"],
|
||||
"position": 1,
|
||||
"is_listed": True,
|
||||
"can_trial": False,
|
||||
@ -137,4 +137,5 @@ class TestRecommendedAppResponseModels:
|
||||
).model_dump(mode="json")
|
||||
|
||||
assert response["recommended_apps"][0]["app_id"] == "app-1"
|
||||
assert response["recommended_apps"][0]["categories"] == ["cat", "other"]
|
||||
assert response["categories"] == ["cat"]
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.recommend_app.category_order import get_explore_app_category_order, order_categories
|
||||
|
||||
|
||||
@patch("services.recommend_app.category_order.redis_client.get")
|
||||
def test_get_explore_app_category_order_returns_redis_list(mock_get):
|
||||
mock_get.return_value = json.dumps(["C", "A", "B"]).encode()
|
||||
|
||||
assert get_explore_app_category_order("en-US") == ["C", "A", "B"]
|
||||
mock_get.assert_called_once_with("explore:apps:category_order:en-US")
|
||||
|
||||
|
||||
@patch("services.recommend_app.category_order.redis_client.get")
|
||||
def test_order_categories_uses_redis_order_as_source_of_truth(mock_get):
|
||||
mock_get.return_value = json.dumps(["C", "A", "B"]).encode()
|
||||
|
||||
assert order_categories({"A", "B", "C", "D"}, "en-US") == ["C", "A", "B"]
|
||||
|
||||
|
||||
@patch("services.recommend_app.category_order.redis_client.get")
|
||||
def test_order_categories_falls_back_to_sorted_categories_without_redis_order(mock_get):
|
||||
mock_get.return_value = None
|
||||
|
||||
assert order_categories({"B", "A", "C"}, "en-US") == ["A", "B", "C"]
|
||||
@ -180,7 +180,7 @@ class TestSetDefaultProvider:
|
||||
session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="provider not found"):
|
||||
BuiltinToolManageService.set_default_provider("t", "u", "p", "id")
|
||||
BuiltinToolManageService.set_default_provider("t", "p", "id")
|
||||
|
||||
@patch(f"{MODULE}.sessionmaker")
|
||||
@patch(f"{MODULE}.db")
|
||||
@ -189,11 +189,29 @@ class TestSetDefaultProvider:
|
||||
target = MagicMock()
|
||||
session.scalar.return_value = target
|
||||
|
||||
result = BuiltinToolManageService.set_default_provider("t", "u", "p", "id")
|
||||
result = BuiltinToolManageService.set_default_provider("t", "p", "id")
|
||||
|
||||
assert result == {"result": "success"}
|
||||
assert target.is_default is True
|
||||
|
||||
@patch(f"{MODULE}.sessionmaker")
|
||||
@patch(f"{MODULE}.db")
|
||||
def test_clear_default_is_tenant_scoped_not_user_scoped(self, mock_db, mock_sm_cls):
|
||||
# Regression: clearing prior defaults must NOT filter by user_id, otherwise
|
||||
# two workspace members can each leave their own credential as default at
|
||||
# the same time (the default flag is tenant-scoped, not per-user).
|
||||
session = _mock_sessionmaker(mock_sm_cls)
|
||||
session.scalar.return_value = MagicMock()
|
||||
|
||||
BuiltinToolManageService.set_default_provider("tenant-1", "google", "cred-id")
|
||||
|
||||
session.execute.assert_called_once()
|
||||
update_stmt = session.execute.call_args.args[0]
|
||||
compiled = str(update_stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "user_id" not in compiled
|
||||
assert "tenant_id" in compiled
|
||||
assert "provider" in compiled
|
||||
|
||||
|
||||
class TestUpdateBuiltinToolProvider:
|
||||
@patch(f"{MODULE}.sessionmaker")
|
||||
|
||||
@ -202,6 +202,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -230,6 +235,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/edit-annotation-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/header-opts/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -252,6 +262,9 @@
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 5
|
||||
},
|
||||
@ -269,11 +282,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/version-info-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -344,6 +352,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
@ -401,6 +412,16 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/configuration-view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/card-item/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -531,6 +552,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 6
|
||||
},
|
||||
@ -580,6 +604,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/workflow-log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
@ -904,6 +931,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/drawer-plus/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/emoji-picker/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -1029,6 +1061,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/float-right-container/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/form/components/base/base-form.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
@ -1233,7 +1270,7 @@
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/development/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/editor/index.ts": {
|
||||
@ -2144,14 +2181,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/batch-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -2162,11 +2191,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/completed/components/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
|
||||
"ts/no-non-null-asserted-optional-chain": {
|
||||
"count": 1
|
||||
@ -2231,14 +2255,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/segment-add/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
@ -2280,6 +2296,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
@ -2319,7 +2338,7 @@
|
||||
},
|
||||
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
|
||||
@ -2813,10 +2832,18 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/model-list.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -2838,6 +2865,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -2896,6 +2926,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@ -2933,16 +2966,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/readme-panel/index.tsx": {
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/readme-panel/store.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@ -3170,7 +3193,7 @@
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
|
||||
@ -3179,6 +3202,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
@ -3187,6 +3213,9 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/test-api.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3196,6 +3225,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/detail/provider-detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3224,12 +3258,20 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/provider/detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/provider/empty.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/setting/build-in/config-credentials.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
@ -4061,6 +4103,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel:
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
|
||||
Utilities:
|
||||
|
||||
@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
|
||||
|
||||
## Overlay & portal contract
|
||||
|
||||
All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
|
||||
Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure.
|
||||
|
||||
### Root isolation requirement
|
||||
|
||||
@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl
|
||||
|
||||
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
|
||||
|
||||
| Layer | z-index | Where |
|
||||
| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
|
||||
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
|
||||
| Layer | z-index | Where |
|
||||
| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
|
||||
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
|
||||
|
||||
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
|
||||
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
|
||||
|
||||
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
|
||||
|
||||
### Rules
|
||||
|
||||
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
|
||||
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
|
||||
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
|
||||
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
|
||||
|
||||
## Development
|
||||
|
||||
@ -37,6 +37,10 @@
|
||||
"types": "./src/dialog/index.tsx",
|
||||
"import": "./src/dialog/index.tsx"
|
||||
},
|
||||
"./drawer": {
|
||||
"types": "./src/drawer/index.tsx",
|
||||
"import": "./src/drawer/index.tsx"
|
||||
},
|
||||
"./dropdown-menu": {
|
||||
"types": "./src/dropdown-menu/index.tsx",
|
||||
"import": "./src/dropdown-menu/index.tsx"
|
||||
|
||||
61
packages/dify-ui/src/drawer/__tests__/index.spec.tsx
Normal file
61
packages/dify-ui/src/drawer/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
DrawerViewport,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Drawer wrapper', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should open a portalled drawer and close it with the default close button', async () => {
|
||||
const screen = await render(
|
||||
<Drawer>
|
||||
<DrawerTrigger>Open settings</DrawerTrigger>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop data-testid="drawer-backdrop" />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup>
|
||||
<DrawerTitle>Settings</DrawerTitle>
|
||||
<DrawerDescription>Configure the current workspace.</DrawerDescription>
|
||||
<DrawerContent>
|
||||
<p>Workspace controls</p>
|
||||
<DrawerCloseButton />
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!)
|
||||
expect(document.body).toContainElement(dialog)
|
||||
expect(screen.container).not.toContainElement(dialog)
|
||||
await expect.element(dialog).toHaveTextContent('Workspace controls')
|
||||
await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002')
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
116
packages/dify-ui/src/drawer/index.tsx
Normal file
116
packages/dify-ui/src/drawer/index.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Drawer as BaseDrawer } from '@base-ui/react/drawer'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export const Drawer = BaseDrawer.Root
|
||||
export const DrawerProvider = BaseDrawer.Provider
|
||||
export const DrawerIndent = BaseDrawer.Indent
|
||||
export const DrawerIndentBackground = BaseDrawer.IndentBackground
|
||||
export const DrawerTrigger = BaseDrawer.Trigger
|
||||
export const DrawerSwipeArea = BaseDrawer.SwipeArea
|
||||
export const DrawerPortal = BaseDrawer.Portal
|
||||
export const DrawerTitle = BaseDrawer.Title
|
||||
export const DrawerDescription = BaseDrawer.Description
|
||||
export const DrawerClose = BaseDrawer.Close
|
||||
export const createDrawerHandle = BaseDrawer.createHandle
|
||||
|
||||
export type DrawerRootProps<Payload = unknown> = BaseDrawer.Root.Props<Payload>
|
||||
export type DrawerRootActions = BaseDrawer.Root.Actions
|
||||
export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails
|
||||
export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason
|
||||
export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint
|
||||
export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails
|
||||
export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason
|
||||
export type DrawerTriggerProps<Payload = unknown> = BaseDrawer.Trigger.Props<Payload>
|
||||
|
||||
export function DrawerBackdrop({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Backdrop.Props) {
|
||||
return (
|
||||
<BaseDrawer.Backdrop
|
||||
className={cn(
|
||||
'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
|
||||
'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DrawerViewport({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Viewport.Props) {
|
||||
return (
|
||||
<BaseDrawer.Viewport
|
||||
className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DrawerPopup({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Popup.Props) {
|
||||
return (
|
||||
<BaseDrawer.Popup
|
||||
className={cn(
|
||||
'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
|
||||
'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none',
|
||||
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
|
||||
'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]',
|
||||
'data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:h-dvh data-[swipe-direction=left]:w-120 data-[swipe-direction=left]:max-w-[calc(100vw-2rem)] data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-l-0 data-[swipe-direction=left]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
|
||||
'data-starting-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))] data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))]',
|
||||
'data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=down]:w-full data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-b-0 data-[swipe-direction=down]:transform-[translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]',
|
||||
'data-starting-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))]',
|
||||
'data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=up]:w-full data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-t-0 data-[swipe-direction=up]:transform-[translateY(var(--drawer-swipe-movement-y,0px))]',
|
||||
'data-starting-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))] data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DrawerContent({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Content.Props) {
|
||||
return (
|
||||
<BaseDrawer.Content
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerCloseButtonProps = Omit<BaseDrawer.Close.Props, 'children'> & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function DrawerCloseButton({
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
'aria-label': ariaLabel = 'Close drawer',
|
||||
...props
|
||||
}: DrawerCloseButtonProps) {
|
||||
return (
|
||||
<BaseDrawer.Close
|
||||
type={type}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span aria-hidden="true" className="i-ri-close-line h-4 w-4" />}
|
||||
</BaseDrawer.Close>
|
||||
)
|
||||
}
|
||||
@ -127,7 +127,7 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
copyright: overrides.copyright ?? '',
|
||||
privacy_policy: overrides.privacy_policy ?? null,
|
||||
custom_disclaimer: overrides.custom_disclaimer ?? null,
|
||||
category: overrides.category ?? 'Writing',
|
||||
categories: overrides.categories ?? ['Writing'],
|
||||
position: overrides.position ?? 1,
|
||||
is_listed: overrides.is_listed ?? true,
|
||||
install_count: overrides.install_count ?? 0,
|
||||
@ -165,9 +165,9 @@ describe('Explore App List Flow', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate', 'Programming'],
|
||||
allList: [
|
||||
createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }),
|
||||
createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }),
|
||||
createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }),
|
||||
createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, categories: ['Writing'] }),
|
||||
createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, categories: ['Translate'] }),
|
||||
createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, categories: ['Programming'] }),
|
||||
],
|
||||
}
|
||||
})
|
||||
@ -190,6 +190,30 @@ describe('Explore App List Flow', () => {
|
||||
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should only use categories when filtering by selected category', () => {
|
||||
mockTabValue = 'Writing'
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [
|
||||
createApp({
|
||||
app_id: 'app-1',
|
||||
app: { ...createApp().app, name: 'Active Writer' },
|
||||
categories: ['Writing'],
|
||||
}),
|
||||
createApp({
|
||||
app_id: 'app-2',
|
||||
app: { ...createApp().app, id: 'app-id-2', name: 'Legacy Writer' },
|
||||
categories: [],
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
renderAppList()
|
||||
|
||||
expect(screen.getByText('Active Writer')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Legacy Writer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter apps by search keyword', async () => {
|
||||
renderAppList()
|
||||
|
||||
|
||||
@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>
|
||||
|
||||
@ -39,7 +39,9 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.set('redirect_url', pathname)
|
||||
const query = params.toString()
|
||||
const fullPath = query ? `${pathname}?${query}` : pathname
|
||||
params.set('redirect_url', fullPath)
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
|
||||
<ContentDialog
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
|
||||
className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col rounded-2xl p-0!"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
|
||||
<div className="flex items-center gap-3 self-stretch">
|
||||
|
||||
@ -20,6 +20,7 @@ const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockWindowOpen = vi.fn()
|
||||
const mockInvalidateAppWorkflow = vi.fn()
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
@ -37,6 +38,7 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
@ -91,6 +93,21 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useWorkflowToolDetailByAppID: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
useInvalidateAllWorkflowTools: () => vi.fn(),
|
||||
useInvalidateWorkflowToolDetailByAppID: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
@ -121,6 +138,15 @@ vi.mock('../../app-access-control', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
|
||||
<div data-testid="workflow-tool-drawer">
|
||||
workflow tool drawer
|
||||
<button onClick={onHide}>close-workflow-tool-drawer</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('../sections', () => ({
|
||||
@ -143,6 +169,13 @@ vi.mock('../sections', () => ({
|
||||
<div>
|
||||
<button onClick={props.handleEmbed}>publisher-embed</button>
|
||||
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
|
||||
{props.handleOpenRunConfig && (
|
||||
<>
|
||||
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>publisher-run-config</button>
|
||||
<button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>publisher-batch-run-config</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -175,6 +208,10 @@ describe('AppPublisher', () => {
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
await resolver()
|
||||
})
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the publish popover and refetch access permission data', async () => {
|
||||
@ -231,6 +268,94 @@ describe('AppPublisher', () => {
|
||||
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collect hidden inputs before opening published run links from config actions', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
inputs={[{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
hide: true,
|
||||
default: '',
|
||||
} as any]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-run-config'))
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Secret'), {
|
||||
target: { value: 'top-secret' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should open batch run config links with the configured hidden inputs', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
inputs={[{
|
||||
variable: 'batch_secret',
|
||||
label: 'Batch Secret',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
hide: true,
|
||||
default: '',
|
||||
} as any]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-batch-run-config'))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Batch Secret'), {
|
||||
target: { value: 'batch-value' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-workflow-tool'))
|
||||
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close embedded and access control panels through child callbacks', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
|
||||
@ -18,8 +18,32 @@ vi.mock('../publish-with-multiple-model', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../suggested-action', () => ({
|
||||
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
|
||||
default: ({
|
||||
children,
|
||||
onClick,
|
||||
link,
|
||||
disabled,
|
||||
actionButton,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: { ariaLabel: string, onClick: () => void }
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
|
||||
{actionButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.ariaLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -170,9 +194,25 @@ describe('app-publisher sections', () => {
|
||||
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should hide access control content when enabled is false', () => {
|
||||
render(
|
||||
<PublisherAccessSection
|
||||
enabled={false}
|
||||
isAppAccessSet
|
||||
isLoading={false}
|
||||
accessMode={AccessMode.PUBLIC}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
|
||||
const handleOpenInExplore = vi.fn()
|
||||
const handleEmbed = vi.fn()
|
||||
const handleOpenRunConfig = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<PublisherActionsSection
|
||||
@ -190,22 +230,30 @@ describe('app-publisher sections', () => {
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
inputs={[]}
|
||||
missingStartNode={false}
|
||||
onRefreshData={vi.fn()}
|
||||
outputs={[]}
|
||||
published={true}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
showBatchRunConfig
|
||||
showRunConfig
|
||||
toolPublished
|
||||
workflowToolAvailable={false}
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
workflowToolIsCurrentWorkspaceManager
|
||||
workflowToolMessage="workflow-disabled"
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[0]!)
|
||||
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[1]!)
|
||||
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch')
|
||||
fireEvent.click(screen.getByText('common.openInExplore'))
|
||||
expect(handleOpenInExplore).toHaveBeenCalled()
|
||||
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
|
||||
@ -223,17 +271,19 @@ describe('app-publisher sections', () => {
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
inputs={[]}
|
||||
missingStartNode
|
||||
onRefreshData={vi.fn()}
|
||||
outputs={[]}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished={false}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
workflowToolIsCurrentWorkspaceManager
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -248,16 +298,19 @@ describe('app-publisher sections', () => {
|
||||
disabledFunctionButton={false}
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
inputs={[]}
|
||||
missingStartNode={false}
|
||||
outputs={[]}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
toolPublished={false}
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
workflowToolOutdated={false}
|
||||
workflowToolIsCurrentWorkspaceManager
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -46,4 +46,47 @@ describe('SuggestedAction', () => {
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render and trigger the trailing action button when configured', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
icon: <span>config</span>,
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Configurable action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(handleActionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should block action button clicks when disabled', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
disabled
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
icon: <span>config</span>,
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Disabled with action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
expect(handleActionClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,28 +1,40 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiStoreLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
|
||||
memo,
|
||||
use,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections'
|
||||
import {
|
||||
buildWorkflowLaunchUrl,
|
||||
createWorkflowLaunchInitialValues,
|
||||
isWorkflowLaunchInputSupported,
|
||||
|
||||
} from '@/app/components/app/overview/app-card-utils'
|
||||
import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
|
||||
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
@ -57,8 +69,8 @@ export type AppPublisherProps = {
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
onPublish?: (params?: any) => Promise<any> | any
|
||||
onRestore?: () => Promise<any> | any
|
||||
onPublish?: AppPublisherPublishHandler
|
||||
onRestore?: AppPublisherRestoreHandler
|
||||
onToggle?: (state: boolean) => void
|
||||
crossAxisOffset?: number
|
||||
toolPublished?: boolean
|
||||
@ -74,6 +86,12 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
| ((params?: unknown) => Promise<unknown> | unknown)
|
||||
|
||||
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
|
||||
|
||||
const AppPublisher = ({
|
||||
disabled = false,
|
||||
publishDisabled = false,
|
||||
@ -100,11 +118,15 @@ const AppPublisher = ({
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false)
|
||||
const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('')
|
||||
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
|
||||
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
|
||||
|
||||
const workflowStore = useContext(WorkflowContext)
|
||||
const workflowStore = use(WorkflowContext)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -113,6 +135,22 @@ const AppPublisher = ({
|
||||
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
const hiddenLaunchVariables = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (inputs ?? []).filter(input => input.hide === true),
|
||||
[inputs],
|
||||
)
|
||||
const supportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const unsupportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const initialWorkflowLaunchValues = useMemo(
|
||||
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
|
||||
[supportedWorkflowLaunchVariables],
|
||||
)
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
@ -222,6 +260,31 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => {
|
||||
setWorkflowLaunchValues(initialWorkflowLaunchValues)
|
||||
setWorkflowLaunchTargetUrl(targetUrl)
|
||||
setWorkflowLaunchDialogOpen(true)
|
||||
}, [initialWorkflowLaunchValues])
|
||||
|
||||
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
|
||||
setWorkflowLaunchValues(prev => ({
|
||||
...prev,
|
||||
[variable]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const targetUrl = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: workflowLaunchTargetUrl,
|
||||
variables: supportedWorkflowLaunchVariables,
|
||||
values: workflowLaunchValues,
|
||||
})
|
||||
|
||||
window.open(targetUrl, '_blank')
|
||||
setWorkflowLaunchDialogOpen(false)
|
||||
}, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues])
|
||||
const handlePublishToMarketplace = useCallback(async () => {
|
||||
if (!appDetail?.id || publishingToMarketplace)
|
||||
return
|
||||
@ -273,6 +336,31 @@ const AppPublisher = ({
|
||||
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
|
||||
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
|
||||
: undefined
|
||||
const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
|
||||
const workflowToolPublished = !!toolPublished
|
||||
const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), [])
|
||||
const workflowToolIcon = useMemo(() => ({
|
||||
content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type])
|
||||
const workflowTool = useConfigureButton({
|
||||
enabled: workflowToolVisible,
|
||||
published: workflowToolPublished,
|
||||
detailNeedUpdate: workflowToolPublished && published,
|
||||
workflowAppId: appDetail?.id ?? '',
|
||||
icon: workflowToolIcon,
|
||||
name: appDetail?.name ?? '',
|
||||
description: appDetail?.description ?? '',
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
onConfigured: closeWorkflowToolDrawer,
|
||||
})
|
||||
const openWorkflowToolDrawer = useCallback(() => {
|
||||
handleOpenChange(false)
|
||||
setWorkflowToolDrawerOpen(true)
|
||||
}, [handleOpenChange])
|
||||
const upgradeHighlightStyle = useMemo(() => ({
|
||||
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
@ -343,23 +431,27 @@ const AppPublisher = ({
|
||||
handleOpenChange(false)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
|
||||
handlePublish={handlePublish}
|
||||
hasHumanInputNode={hasHumanInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
inputs={inputs}
|
||||
missingStartNode={missingStartNode}
|
||||
onRefreshData={onRefreshData}
|
||||
outputs={outputs}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)}
|
||||
showRunConfig={hiddenLaunchVariables.length > 0}
|
||||
toolPublished={toolPublished}
|
||||
workflowToolAvailable={workflowToolAvailable}
|
||||
workflowToolIsLoading={workflowTool.isLoading}
|
||||
workflowToolOutdated={workflowTool.outdated}
|
||||
workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager}
|
||||
workflowToolMessage={workflowToolMessage}
|
||||
onConfigureWorkflowTool={openWorkflowToolDrawer}
|
||||
/>
|
||||
{systemFeatures.enable_creators_platform && (
|
||||
<div className="border-t border-divider-subtle p-4">
|
||||
<SuggestedAction
|
||||
icon={<RiStoreLine className="h-4 w-4" />}
|
||||
icon={<span className="i-ri-store-line h-4 w-4" />}
|
||||
disabled={!publishedAt || publishingToMarketplace}
|
||||
onClick={handlePublishToMarketplace}
|
||||
>
|
||||
@ -377,9 +469,29 @@ const AppPublisher = ({
|
||||
onClose={() => setEmbeddingModalOpen(false)}
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
|
||||
<WorkflowLaunchDialog
|
||||
t={t}
|
||||
open={workflowLaunchDialogOpen}
|
||||
hiddenVariables={supportedWorkflowLaunchVariables}
|
||||
unsupportedVariables={unsupportedWorkflowLaunchVariables}
|
||||
values={workflowLaunchValues}
|
||||
onOpenChange={setWorkflowLaunchDialogOpen}
|
||||
onValueChange={handleWorkflowLaunchValueChange}
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</Popover>
|
||||
{workflowToolDrawerOpen && (
|
||||
<WorkflowToolDrawer
|
||||
isAdd={!workflowToolPublished}
|
||||
payload={workflowTool.payload}
|
||||
onHide={closeWorkflowToolDrawer}
|
||||
onCreate={workflowTool.handleCreate}
|
||||
onSave={workflowTool.handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,13 +8,12 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
@ -46,11 +45,8 @@ type AccessSectionProps = {
|
||||
|
||||
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
|
||||
| 'hasTriggerNode'
|
||||
| 'inputs'
|
||||
| 'missingStartNode'
|
||||
| 'onRefreshData'
|
||||
| 'toolPublished'
|
||||
| 'outputs'
|
||||
| 'publishedAt'
|
||||
| 'workflowToolAvailable'> & {
|
||||
appDetail: {
|
||||
@ -67,9 +63,16 @@ type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
|
||||
disabledFunctionTooltip?: string
|
||||
handleEmbed: () => void
|
||||
handleOpenInExplore: () => void
|
||||
handleOpenRunConfig?: (url: string) => void
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
published: boolean
|
||||
showBatchRunConfig?: boolean
|
||||
showRunConfig?: boolean
|
||||
workflowToolIsLoading: boolean
|
||||
workflowToolOutdated: boolean
|
||||
workflowToolIsCurrentWorkspaceManager: boolean
|
||||
workflowToolMessage?: string
|
||||
onConfigureWorkflowTool: () => void
|
||||
}
|
||||
|
||||
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
|
||||
@ -256,18 +259,20 @@ export const PublisherActionsSection = ({
|
||||
disabledFunctionTooltip,
|
||||
handleEmbed,
|
||||
handleOpenInExplore,
|
||||
handlePublish,
|
||||
handleOpenRunConfig,
|
||||
hasHumanInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
inputs,
|
||||
missingStartNode = false,
|
||||
onRefreshData,
|
||||
outputs,
|
||||
published,
|
||||
publishedAt,
|
||||
showBatchRunConfig = false,
|
||||
showRunConfig = false,
|
||||
toolPublished,
|
||||
workflowToolAvailable = true,
|
||||
workflowToolIsLoading,
|
||||
workflowToolOutdated,
|
||||
workflowToolIsCurrentWorkspaceManager,
|
||||
workflowToolMessage,
|
||||
onConfigureWorkflowTool,
|
||||
}: ActionsSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -284,6 +289,13 @@ export const PublisherActionsSection = ({
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
actionButton={showRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(appURL),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -296,6 +308,13 @@ export const PublisherActionsSection = ({
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
actionButton={showBatchRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -305,7 +324,7 @@ export const PublisherActionsSection = ({
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -340,18 +359,10 @@ export const PublisherActionsSection = ({
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id ?? ''}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name ?? ''}
|
||||
description={appDetail?.description ?? ''}
|
||||
inputs={inputs}
|
||||
outputs={outputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
isLoading={workflowToolIsLoading}
|
||||
outdated={workflowToolOutdated}
|
||||
isCurrentWorkspaceManager={workflowToolIsCurrentWorkspaceManager}
|
||||
onConfigure={onConfigureWorkflowTool}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,33 +1,93 @@
|
||||
import type { HTMLProps, PropsWithChildren } from 'react'
|
||||
import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
|
||||
type SuggestedActionButton = {
|
||||
ariaLabel: string
|
||||
icon: React.ReactNode
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
|
||||
icon?: React.ReactNode
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: SuggestedActionButton
|
||||
}>
|
||||
|
||||
const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled)
|
||||
const SuggestedAction = ({
|
||||
icon,
|
||||
link,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
actionButton,
|
||||
...props
|
||||
}: SuggestedActionProps) => {
|
||||
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
onClick?.(event)
|
||||
}
|
||||
return (
|
||||
|
||||
const handleActionClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
actionButton?.onClick(event)
|
||||
}
|
||||
|
||||
const mainAction = (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors not-first:mt-1', disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent', className)}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors',
|
||||
actionButton ? 'flex-1 rounded-l-lg' : 'rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative h-4 w-4">{icon}</div>
|
||||
<div className="relative h-4 w-4 shrink-0">{icon}</div>
|
||||
<div className="shrink grow basis-0 system-sm-medium">{children}</div>
|
||||
<RiArrowRightUpLine className="h-3.5 w-3.5" />
|
||||
<RiArrowRightUpLine className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
|
||||
if (!actionButton)
|
||||
return mainAction
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled ? 'opacity-30 shadow-xs' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{mainAction}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleActionClick}
|
||||
>
|
||||
{actionButton.icon}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuggestedAction
|
||||
|
||||
@ -4,6 +4,29 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigModalFormFields from '../form-fields'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const ns = options?.ns as string | undefined
|
||||
return ns ? `${ns}.${key}` : key
|
||||
},
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, ReactNode> }) => (
|
||||
<span data-i18n-key={i18nKey}>
|
||||
{i18nKey}
|
||||
{components?.docLink}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
@ -74,6 +97,12 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../field', () => ({
|
||||
default: ({ children, title }: { children: ReactNode, title: string }) => (
|
||||
<div>
|
||||
@ -176,7 +205,18 @@ describe('ConfigModalFormFields', () => {
|
||||
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
|
||||
})
|
||||
|
||||
it('should wire file, json schema, and visibility controls', () => {
|
||||
it('should wire file, json schema, and visibility controls', async () => {
|
||||
const textInputProps = createBaseProps()
|
||||
const textInputView = render(<ConfigModalFormFields {...textInputProps} />)
|
||||
expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' }))
|
||||
expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument()
|
||||
const docLink = await screen.findByRole('link')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
textInputView.unmount()
|
||||
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
@ -185,18 +225,20 @@ describe('ConfigModalFormFields', () => {
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
}
|
||||
render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
const singleFileView = render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('single-file-setting'))
|
||||
fireEvent.click(screen.getByText('upload-file'))
|
||||
fireEvent.click(screen.getAllByText('unchecked')[0]!)
|
||||
fireEvent.click(screen.getAllByText('unchecked')[1]!)
|
||||
|
||||
expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 })
|
||||
expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileId: 'file-1',
|
||||
}))
|
||||
expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true)
|
||||
expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true)
|
||||
expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled()
|
||||
singleFileView.unmount()
|
||||
|
||||
const multiFileProps = createBaseProps()
|
||||
multiFileProps.tempPayload = {
|
||||
@ -207,8 +249,9 @@ describe('ConfigModalFormFields', () => {
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFileProps} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('multi-file-setting'))
|
||||
fireEvent.click(screen.getAllByText('upload-file')[1]!)
|
||||
fireEvent.click(screen.getAllByText('upload-file')[0]!)
|
||||
expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 })
|
||||
expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ fileId: 'file-1' }),
|
||||
@ -367,4 +410,23 @@ describe('ConfigModalFormFields', () => {
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
})
|
||||
|
||||
it('should disable hide checkbox when required is true and disable required when hide is true', () => {
|
||||
const requiredProps = createBaseProps()
|
||||
requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false }
|
||||
const { unmount } = render(<ConfigModalFormFields {...requiredProps} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0])
|
||||
expect(hideButton).toBeDefined()
|
||||
unmount()
|
||||
|
||||
const hideProps = createBaseProps()
|
||||
hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true }
|
||||
render(<ConfigModalFormFields {...hideProps} />)
|
||||
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked')
|
||||
expect(checkedHideButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -25,6 +25,7 @@ vi.mock('../form-fields', () => ({
|
||||
return (
|
||||
<div data-testid="config-form-fields">
|
||||
<div data-testid="payload-type">{String(props.tempPayload.type)}</div>
|
||||
<div data-testid="payload-hide">{String(props.tempPayload.hide)}</div>
|
||||
<div data-testid="payload-label">{String(props.tempPayload.label ?? '')}</div>
|
||||
<div data-testid="payload-schema">{String(props.tempPayload.json_schema ?? '')}</div>
|
||||
<div data-testid="payload-default">{String(props.tempPayload.default ?? '')}</div>
|
||||
@ -115,7 +116,7 @@ describe('ConfigModal logic', () => {
|
||||
})
|
||||
|
||||
it('should derive payload fields from mocked form-field callbacks', async () => {
|
||||
renderConfigModal()
|
||||
renderConfigModal(createPayload({ hide: true }))
|
||||
|
||||
fireEvent.click(screen.getByTestId('valid-key-blur'))
|
||||
await waitFor(() => {
|
||||
@ -138,6 +139,7 @@ describe('ConfigModal logic', () => {
|
||||
fireEvent.click(screen.getByTestId('type-change'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile)
|
||||
expect(screen.getByTestId('payload-hide')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-payload-change'))
|
||||
|
||||
@ -49,11 +49,13 @@ describe('config-modal utils', () => {
|
||||
const payload = createInputVar({
|
||||
type: InputVarType.textInput,
|
||||
default: 'hello',
|
||||
hide: true,
|
||||
})
|
||||
|
||||
const nextPayload = createPayloadForType(payload, InputVarType.multiFiles)
|
||||
|
||||
expect(nextPayload.type).toBe(InputVarType.multiFiles)
|
||||
expect(nextPayload.hide).toBe(false)
|
||||
expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length)
|
||||
expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types)
|
||||
expect(nextPayload.default).toBe('hello')
|
||||
@ -249,6 +251,24 @@ describe('config-modal utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should force file inputs to stay visible when saving', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
hide: true,
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: [],
|
||||
}),
|
||||
payload: createInputVar(),
|
||||
checkVariableName: () => true,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result.payloadToSave).toEqual(expect.objectContaining({
|
||||
hide: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should stop validation when the variable name checker rejects the payload', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
|
||||
@ -13,14 +13,17 @@ import {
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
@ -68,6 +71,9 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
t,
|
||||
}) => {
|
||||
const { type, label, variable } = tempPayload
|
||||
const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type)
|
||||
const docLink = useDocLink()
|
||||
const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@ -105,7 +111,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={tempPayload.default || ''}
|
||||
value={typeof tempPayload.default === 'string' ? tempPayload.default : ''}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
@ -126,7 +132,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempPayload.default || ''}
|
||||
value={typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' ? tempPayload.default : ''}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
@ -186,7 +192,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
|
||||
{isFileInput && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
@ -227,14 +233,37 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
)}
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
|
||||
<Checkbox checked={tempPayload.required} disabled={!isFileInput && tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
{!isFileInput && (
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hidden', { ns: 'appDebug' })}</span>
|
||||
<Infotip
|
||||
aria-label={hiddenDescriptionAriaLabel}
|
||||
popupClassName="max-w-[300px]"
|
||||
>
|
||||
<Trans
|
||||
i18nKey="variableConfig.hiddenDescription"
|
||||
ns="appDebug"
|
||||
components={{
|
||||
docLink: (
|
||||
<a
|
||||
href={docLink('/use-dify/nodes/user-input#hide-and-pre-fill-input-fields')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-accent hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Infotip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -88,7 +88,9 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => {
|
||||
draft.default = undefined
|
||||
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>).forEach((key) => {
|
||||
draft.hide = false
|
||||
const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>
|
||||
fileUploadSettingKeys.forEach((key) => {
|
||||
if (key !== 'max_length')
|
||||
draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never
|
||||
})
|
||||
@ -158,38 +160,41 @@ export const validateConfigModalPayload = ({
|
||||
checkVariableName,
|
||||
t,
|
||||
}: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => {
|
||||
const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)
|
||||
? { ...tempPayload, hide: false }
|
||||
: tempPayload
|
||||
const jsonSchemaValue = tempPayload.json_schema
|
||||
const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
|
||||
const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue
|
||||
const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty
|
||||
? { ...normalizedTempPayload, json_schema: undefined }
|
||||
: normalizedTempPayload
|
||||
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
const moreInfo = normalizedTempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable },
|
||||
}
|
||||
|
||||
if (!checkVariableName(tempPayload.variable))
|
||||
if (!checkVariableName(normalizedTempPayload.variable))
|
||||
return {}
|
||||
|
||||
if (!tempPayload.label) {
|
||||
if (!normalizedTempPayload.label) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (tempPayload.type === InputVarType.select) {
|
||||
if (!tempPayload.options?.length) {
|
||||
if (normalizedTempPayload.type === InputVarType.select) {
|
||||
if (!normalizedTempPayload.options?.length) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
|
||||
const duplicated = new Set<string>()
|
||||
const hasRepeatedItem = tempPayload.options.some((option) => {
|
||||
const hasRepeatedItem = normalizedTempPayload.options.some((option) => {
|
||||
if (duplicated.has(option))
|
||||
return true
|
||||
|
||||
@ -204,8 +209,8 @@ export const validateConfigModalPayload = ({
|
||||
}
|
||||
}
|
||||
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) {
|
||||
if (!tempPayload.allowed_file_types?.length) {
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) {
|
||||
if (!normalizedTempPayload.allowed_file_types?.length) {
|
||||
return {
|
||||
errorMessage: t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
@ -214,7 +219,7 @@ export const validateConfigModalPayload = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) {
|
||||
return {
|
||||
errorMessage: t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
@ -224,7 +229,7 @@ export const validateConfigModalPayload = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
if (schema?.type !== 'object') {
|
||||
|
||||
@ -35,7 +35,7 @@ const mockApp: App = {
|
||||
copyright: 'Test Corp',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
categories: ['Assistant'],
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 100,
|
||||
@ -253,7 +253,7 @@ describe('AppCard', () => {
|
||||
template_id: mockApp.app_id,
|
||||
template_name: mockApp.app.name,
|
||||
template_mode: mockApp.app.mode,
|
||||
template_category: mockApp.category,
|
||||
template_categories: mockApp.categories,
|
||||
page: 'studio',
|
||||
})
|
||||
expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, {
|
||||
|
||||
@ -35,7 +35,7 @@ const AppCard = ({
|
||||
template_id: app.app_id,
|
||||
template_name: appBasicInfo.name,
|
||||
template_mode: appBasicInfo.mode,
|
||||
template_category: app.category,
|
||||
template_categories: app.categories,
|
||||
page: 'studio',
|
||||
})
|
||||
setShowTryAppPanel?.(true, { appId: app.app_id, app })
|
||||
|
||||
@ -115,7 +115,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
const createAppEntry = (name: string, category: string) => ({
|
||||
app_id: name,
|
||||
category,
|
||||
categories: [category],
|
||||
app: {
|
||||
id: name,
|
||||
name,
|
||||
|
||||
@ -74,7 +74,7 @@ const Apps = ({
|
||||
const filteredByCategory = allList.filter((item) => {
|
||||
if (currCategory === allCategoriesEn)
|
||||
return true
|
||||
return item.category === currCategory
|
||||
return item.categories?.includes(currCategory) ?? false
|
||||
})
|
||||
if (currentType.length === 0)
|
||||
return filteredByCategory
|
||||
|
||||
@ -1,8 +1,38 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections'
|
||||
import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../settings', () => ({
|
||||
default: () => <div data-testid="settings-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../embedded', () => ({
|
||||
default: () => <div data-testid="embedded-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../customize', () => ({
|
||||
default: () => <div data-testid="customize-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button type="button" onClick={onClose}>close-access</button>
|
||||
<button type="button" onClick={onConfirm}>confirm-access</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('app-card-sections', () => {
|
||||
const t = (key: string) => key
|
||||
@ -52,6 +82,7 @@ describe('app-card-sections', () => {
|
||||
|
||||
it('should render operation buttons and execute enabled actions', () => {
|
||||
const onLaunch = vi.fn()
|
||||
const onLaunchConfig = vi.fn()
|
||||
const operations = createAppCardOperations({
|
||||
operationKeys: ['launch', 'embedded'],
|
||||
t: t as never,
|
||||
@ -68,12 +99,19 @@ describe('app-card-sections', () => {
|
||||
<AppCardOperations
|
||||
t={t as never}
|
||||
operations={operations}
|
||||
launchConfigAction={{
|
||||
label: 'operation.config',
|
||||
disabled: false,
|
||||
onClick: onLaunchConfig,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.config/i }))
|
||||
|
||||
expect(onLaunch).toHaveBeenCalledTimes(1)
|
||||
expect(onLaunchConfig).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -127,4 +165,127 @@ describe('app-card-sections', () => {
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i }))
|
||||
expect(onRegenerate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable all operations when triggerModeDisabled is true', () => {
|
||||
const operations = createAppCardOperations({
|
||||
operationKeys: ['launch', 'settings'],
|
||||
t: t as never,
|
||||
runningStatus: true,
|
||||
triggerModeDisabled: true,
|
||||
onLaunch: vi.fn(),
|
||||
onEmbedded: vi.fn(),
|
||||
onCustomize: vi.fn(),
|
||||
onSettings: vi.fn(),
|
||||
onDevelop: vi.fn(),
|
||||
})
|
||||
|
||||
expect(operations[0]!.disabled).toBe(true)
|
||||
expect(operations[1]!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should render WorkflowLaunchDialog and submit values', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onValueChange = vi.fn()
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
render(
|
||||
<WorkflowLaunchDialog
|
||||
t={t as never}
|
||||
open
|
||||
hiddenVariables={[{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
}]}
|
||||
unsupportedVariables={[]}
|
||||
values={{ secret: 'hello' }}
|
||||
onOpenChange={onOpenChange}
|
||||
onValueChange={onValueChange}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!)
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return null for WorkflowLaunchDialog when no variables are provided', () => {
|
||||
const { container } = render(
|
||||
<WorkflowLaunchDialog
|
||||
t={t as never}
|
||||
open
|
||||
hiddenVariables={[]}
|
||||
unsupportedVariables={[]}
|
||||
values={{}}
|
||||
onOpenChange={vi.fn()}
|
||||
onValueChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render AppCardDialogs with all modals for web apps', () => {
|
||||
const appInfo = {
|
||||
id: 'app-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
enable_site: true,
|
||||
enable_api: false,
|
||||
site: { app_base_url: 'https://example.com', access_token: 'token-1' },
|
||||
api_base_url: 'https://api.example.com',
|
||||
} as never
|
||||
|
||||
render(
|
||||
<AppCardDialogs
|
||||
isApp
|
||||
appInfo={appInfo}
|
||||
appMode={AppModeEnum.CHAT}
|
||||
showSettingsModal
|
||||
showEmbedded
|
||||
showCustomizeModal
|
||||
showAccessControl
|
||||
appDetail={{ id: 'app-1', access_mode: AccessMode.PUBLIC } as AppDetailResponse}
|
||||
onCloseSettings={vi.fn()}
|
||||
onCloseEmbedded={vi.fn()}
|
||||
onCloseCustomize={vi.fn()}
|
||||
onCloseAccessControl={vi.fn()}
|
||||
onSaveSiteConfig={vi.fn()}
|
||||
onConfirmAccessControl={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('settings-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('customize-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null for AppCardDialogs when not an app', () => {
|
||||
const { container } = render(
|
||||
<AppCardDialogs
|
||||
isApp={false}
|
||||
appInfo={{} as never}
|
||||
appMode={AppModeEnum.CHAT}
|
||||
showSettingsModal={false}
|
||||
showEmbedded={false}
|
||||
showCustomizeModal={false}
|
||||
showAccessControl={false}
|
||||
appDetail={null}
|
||||
onCloseSettings={vi.fn()}
|
||||
onCloseEmbedded={vi.fn()}
|
||||
onCloseCustomize={vi.fn()}
|
||||
onCloseAccessControl={vi.fn()}
|
||||
onSaveSiteConfig={vi.fn()}
|
||||
onConfirmAccessControl={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils'
|
||||
import {
|
||||
buildWorkflowLaunchUrl,
|
||||
compressAndEncodeBase64,
|
||||
createWorkflowLaunchInitialValues,
|
||||
getAppCardDisplayState,
|
||||
getAppCardOperationKeys,
|
||||
getAppHiddenLaunchVariables,
|
||||
getEmbeddedIframeSnippet,
|
||||
getEmbeddedScriptSnippet,
|
||||
getWorkflowHiddenStartVariables,
|
||||
hasWorkflowStartNode,
|
||||
isAppAccessConfigured,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from '../app-card-utils'
|
||||
|
||||
describe('app-card-utils', () => {
|
||||
const baseAppInfo = {
|
||||
@ -33,6 +46,108 @@ describe('app-card-utils', () => {
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('should return hidden workflow start variables and their initial launch values', () => {
|
||||
const hiddenVariables = getWorkflowHiddenStartVariables({
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
variables: [
|
||||
{
|
||||
variable: 'visible',
|
||||
label: 'Visible',
|
||||
type: InputVarType.textInput,
|
||||
hide: false,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
default: 'prefilled',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
variable: 'enabled',
|
||||
label: 'Enabled',
|
||||
type: InputVarType.checkbox,
|
||||
hide: true,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled'])
|
||||
expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({
|
||||
secret: 'prefilled',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return hidden advanced-chat launch variables from the workflow start node first', () => {
|
||||
const hiddenVariables = getAppHiddenLaunchVariables({
|
||||
appInfo: {
|
||||
...baseAppInfo,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
model_config: {
|
||||
user_input_form: [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Visible',
|
||||
variable: 'visible',
|
||||
required: true,
|
||||
max_length: 48,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
checkbox: {
|
||||
label: 'Hidden Toggle',
|
||||
variable: 'hidden_toggle',
|
||||
required: false,
|
||||
default: true,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as AppDetailResponse,
|
||||
currentWorkflow: {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
variables: [
|
||||
{
|
||||
variable: 'start_secret',
|
||||
label: 'Start Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
default: 'from-start',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(hiddenVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
variable: 'start_secret',
|
||||
type: InputVarType.textInput,
|
||||
default: 'from-start',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should build the display state for a published web app', () => {
|
||||
const state = getAppCardDisplayState({
|
||||
appInfo: baseAppInfo,
|
||||
@ -104,4 +219,108 @@ describe('app-card-utils', () => {
|
||||
isCurrentWorkspaceEditor: false,
|
||||
})).toEqual(['launch', 'embedded', 'customize'])
|
||||
})
|
||||
|
||||
it('should build a workflow launch URL with serialized parameters', async () => {
|
||||
const url = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: 'https://example.com/app/workflow/token-1',
|
||||
variables: [
|
||||
{ variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false },
|
||||
{ variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false },
|
||||
],
|
||||
values: { name: 'Alice', enabled: true },
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get('name')).toBe('Alice')
|
||||
expect(parsed.searchParams.get('enabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('should serialize checkbox false and empty string values in launch URL', async () => {
|
||||
const url = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: 'https://example.com/app/workflow/token-1',
|
||||
variables: [
|
||||
{ variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false },
|
||||
{ variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false },
|
||||
],
|
||||
values: { flag: false, empty: '' },
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get('flag')).toBe('false')
|
||||
expect(parsed.searchParams.get('empty')).toBe('')
|
||||
})
|
||||
|
||||
it('should generate an iframe snippet with the provided URL', () => {
|
||||
const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1')
|
||||
expect(snippet).toContain('src="https://example.com/chatbot/token-1"')
|
||||
expect(snippet).toContain('frameborder="0"')
|
||||
expect(snippet).toContain('allow="microphone"')
|
||||
})
|
||||
|
||||
it('should generate an embedded script snippet with inputs', () => {
|
||||
const snippet = getEmbeddedScriptSnippet({
|
||||
url: 'https://example.com',
|
||||
token: 'abc123',
|
||||
primaryColor: '#FF0000',
|
||||
isTestEnv: true,
|
||||
inputValues: { name: 'Alice', count: '5' },
|
||||
})
|
||||
|
||||
expect(snippet).toContain('token: \'abc123\'')
|
||||
expect(snippet).toContain('isDev: true')
|
||||
expect(snippet).toContain('name: "Alice"')
|
||||
expect(snippet).toContain('count: "5"')
|
||||
expect(snippet).toContain('background-color: #FF0000')
|
||||
})
|
||||
|
||||
it('should generate an embedded script snippet with empty inputs comment', () => {
|
||||
const snippet = getEmbeddedScriptSnippet({
|
||||
url: 'https://example.com',
|
||||
token: 'abc123',
|
||||
primaryColor: '#1C64F2',
|
||||
inputValues: {},
|
||||
})
|
||||
|
||||
expect(snippet).toContain('// You can define the inputs from the Start node here')
|
||||
expect(snippet).not.toContain('isDev: true')
|
||||
})
|
||||
|
||||
it('should compress and encode base64 using CompressionStream when available', async () => {
|
||||
const result = await compressAndEncodeBase64('hello')
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should fallback to plain base64 when CompressionStream is unavailable', async () => {
|
||||
const original = globalThis.CompressionStream
|
||||
// @ts-expect-error remove for test
|
||||
delete globalThis.CompressionStream
|
||||
|
||||
const result = await compressAndEncodeBase64('hello')
|
||||
expect(result).toBe(btoa('hello'))
|
||||
|
||||
globalThis.CompressionStream = original
|
||||
})
|
||||
|
||||
it('should identify supported workflow launch input types', () => {
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => {
|
||||
const result = createWorkflowLaunchInitialValues([
|
||||
{ variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 },
|
||||
{ variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false },
|
||||
])
|
||||
|
||||
expect(result).toEqual({ count: '42', empty: '' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -17,7 +18,7 @@ const mockSetAppDetail = vi.fn()
|
||||
const mockOnChangeStatus = vi.fn()
|
||||
const mockOnGenerateCode = vi.fn()
|
||||
|
||||
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null
|
||||
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null
|
||||
let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
|
||||
let mockAppDetail: AppDetailResponse | undefined
|
||||
|
||||
@ -25,6 +26,7 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@ -164,6 +166,182 @@ describe('AppCard', () => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank')
|
||||
})
|
||||
|
||||
it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('overview.appInfo.launch'))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/access-token`,
|
||||
'_blank',
|
||||
)
|
||||
expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.config' }))
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Secret'), {
|
||||
target: { value: 'top-secret' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the chat web app directly when launch is clicked even with hidden inputs', () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'chat_secret',
|
||||
label: 'Chat Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
} as AppDetailResponse}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('overview.appInfo.launch'))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/chat/access-token`,
|
||||
'_blank',
|
||||
)
|
||||
expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'chat_secret',
|
||||
label: 'Chat Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
} as AppDetailResponse}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.config' }))
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Chat Secret'), {
|
||||
target: { value: 'chat-secret' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the access-control not-set badge when specific access has no subjects', () => {
|
||||
render(
|
||||
<AppCard
|
||||
@ -302,7 +480,7 @@ describe('AppCard', () => {
|
||||
})
|
||||
|
||||
it('should report refresh failures from access control updates', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
|
||||
render(
|
||||
|
||||
@ -0,0 +1,214 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
|
||||
|
||||
describe('WorkflowHiddenInputFields', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render a text input with label and placeholder', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'name',
|
||||
label: 'Full Name',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
}]}
|
||||
values={{ name: 'Alice' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Full Name')
|
||||
expect(input).toHaveValue('Alice')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Bob' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('name', 'Bob')
|
||||
})
|
||||
|
||||
it('should render a number input for number-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'count',
|
||||
label: 'Count',
|
||||
type: InputVarType.number,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ count: '5' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Count')
|
||||
expect(input).toHaveAttribute('type', 'number')
|
||||
|
||||
fireEvent.change(input, { target: { value: '10' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('count', '10')
|
||||
})
|
||||
|
||||
it('should render a checkbox input without a separate label element above', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'enabled',
|
||||
label: 'Enable Feature',
|
||||
type: InputVarType.checkbox,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ enabled: true }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).toBeChecked()
|
||||
expect(screen.getByText('Enable Feature')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(onValueChange).toHaveBeenCalledWith('enabled', false)
|
||||
})
|
||||
|
||||
it('should render a select dropdown for select-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'color',
|
||||
label: 'Color',
|
||||
type: InputVarType.select,
|
||||
hide: true,
|
||||
required: false,
|
||||
options: ['red', 'green', 'blue'],
|
||||
}]}
|
||||
values={{ color: 'red' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a textarea for paragraph-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'description',
|
||||
label: 'Description',
|
||||
type: InputVarType.paragraph,
|
||||
hide: true,
|
||||
required: false,
|
||||
max_length: 500,
|
||||
}]}
|
||||
values={{ description: 'Hello world' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Description')
|
||||
expect(textarea).toHaveValue('Hello world')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'Updated' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('description', 'Updated')
|
||||
})
|
||||
|
||||
it('should render a textarea for json-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'config',
|
||||
label: 'Config JSON',
|
||||
type: InputVarType.json,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ config: '{"key": "value"}' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Config JSON')
|
||||
expect(textarea).toHaveValue('{"key": "value"}')
|
||||
})
|
||||
|
||||
it('should render a textarea for jsonObject-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'schema',
|
||||
label: 'Schema',
|
||||
type: InputVarType.jsonObject,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ schema: '{}' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Schema')
|
||||
expect(textarea).toHaveValue('{}')
|
||||
})
|
||||
|
||||
it('should use the variable key as label when label is not a string', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'my_var',
|
||||
label: { nodeType: 'start' as never, nodeName: 'Start', variable: 'my_var' },
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ my_var: '' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('my_var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use the custom fieldIdPrefix for element ids', () => {
|
||||
const { container } = render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'token',
|
||||
label: 'Token',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ token: 'abc' }}
|
||||
onValueChange={onValueChange}
|
||||
fieldIdPrefix="custom-prefix"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty string for non-string fieldValue in text inputs', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'flag',
|
||||
label: 'Flag',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ flag: true as never }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Flag')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,11 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { OverviewOperationKey } from './app-card-utils'
|
||||
import type { ComponentType, FormEvent, ReactNode } from 'react'
|
||||
import type {
|
||||
OverviewOperationKey,
|
||||
WorkflowHiddenStartVariable,
|
||||
WorkflowLaunchInputValue,
|
||||
} from './app-card-utils'
|
||||
import type { ConfigParams } from './settings'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
@ -15,12 +19,19 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
|
||||
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ShareQRCode from '@/app/components/base/qrcode'
|
||||
@ -31,6 +42,7 @@ import CustomizeModal from './customize'
|
||||
import EmbeddedModal from './embedded'
|
||||
import SettingsModal from './settings'
|
||||
import style from './style.module.css'
|
||||
import WorkflowHiddenInputFields from './workflow-hidden-input-fields'
|
||||
|
||||
type AppInfo = AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
@ -50,6 +62,12 @@ type AppCardOperation = {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type LaunchConfigAction = {
|
||||
label: string
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const OPERATION_ICON_MAP: Record<OverviewOperationKey, OperationIcon> = {
|
||||
launch: RiExternalLinkLine,
|
||||
embedded: RiWindowLine,
|
||||
@ -96,6 +114,65 @@ const MaybeTooltip = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowLaunchDialog = ({
|
||||
t,
|
||||
open,
|
||||
hiddenVariables,
|
||||
unsupportedVariables,
|
||||
values,
|
||||
onOpenChange,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
t: TFunction
|
||||
open: boolean
|
||||
hiddenVariables: WorkflowHiddenStartVariable[]
|
||||
unsupportedVariables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
}) => {
|
||||
if (!hiddenVariables.length && !unsupportedVariables.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[560px]! max-w-[calc(100vw-2rem)]! p-0!">
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="system-md-regular text-text-tertiary">
|
||||
<Trans
|
||||
i18nKey="overview.appInfo.workflowLaunchHiddenInputs.description"
|
||||
ns="appOverview"
|
||||
components={{ bold: <span className="system-md-medium" /> }}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4 px-6 pb-4">
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={hiddenVariables}
|
||||
values={values}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
{t('overview.appInfo.launch', { ns: 'appOverview' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const createAppCardOperations = ({
|
||||
operationKeys,
|
||||
t,
|
||||
@ -251,20 +328,15 @@ export const AppCardAccessControlSection = ({
|
||||
export const AppCardOperations = ({
|
||||
t,
|
||||
operations,
|
||||
launchConfigAction,
|
||||
}: {
|
||||
t: TFunction
|
||||
operations: AppCardOperation[]
|
||||
launchConfigAction?: LaunchConfigAction
|
||||
}) => (
|
||||
<>
|
||||
{operations.map(({ key, label, Icon, disabled, onClick }) => (
|
||||
<Button
|
||||
className="mr-1 min-w-[88px]"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
key={key}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{operations.map(({ key, label, Icon, disabled, onClick }) => {
|
||||
const buttonContent = (
|
||||
<MaybeTooltip
|
||||
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
|
||||
tooltipClassName="mt-[-8px]"
|
||||
@ -275,8 +347,72 @@ export const AppCardOperations = ({
|
||||
<div className={`${disabled ? 'text-components-button-ghost-text-disabled' : 'text-text-tertiary'} px-[3px] system-xs-medium`}>{label}</div>
|
||||
</div>
|
||||
</MaybeTooltip>
|
||||
</Button>
|
||||
))}
|
||||
)
|
||||
|
||||
if (key === 'launch' && launchConfigAction) {
|
||||
return (
|
||||
<MaybeTooltip
|
||||
key={key}
|
||||
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
|
||||
tooltipClassName="mt-[-8px]"
|
||||
show={disabled}
|
||||
>
|
||||
<Button
|
||||
className="mr-1 border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg"
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex h-full min-w-[88px] items-center justify-center rounded-l-md px-2 hover:bg-components-button-secondary-bg-hover">
|
||||
<div className="flex items-center justify-center gap-px">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<div className="px-[3px] system-xs-medium">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-4 w-px shrink-0 bg-divider-regular opacity-100"
|
||||
/>
|
||||
<div
|
||||
className="flex h-full w-8 shrink-0 items-center justify-center rounded-r-md hover:bg-components-button-secondary-bg-hover"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
launchConfigAction.onClick()
|
||||
}}
|
||||
aria-label={launchConfigAction.label}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
launchConfigAction.onClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RiSettings2Line className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</Button>
|
||||
</MaybeTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="mr-1 min-w-[88px]"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
key={key}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -295,6 +431,7 @@ export const AppCardDialogs = ({
|
||||
onCloseAccessControl,
|
||||
onSaveSiteConfig,
|
||||
onConfirmAccessControl,
|
||||
hiddenInputs,
|
||||
}: {
|
||||
isApp: boolean
|
||||
appInfo: AppInfo
|
||||
@ -310,6 +447,7 @@ export const AppCardDialogs = ({
|
||||
onCloseAccessControl: () => void
|
||||
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
|
||||
onConfirmAccessControl: () => Promise<void>
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
}) => {
|
||||
if (!isApp)
|
||||
return null
|
||||
@ -329,6 +467,7 @@ export const AppCardDialogs = ({
|
||||
onClose={onCloseEmbedded}
|
||||
appBaseUrl={appInfo.site?.app_base_url}
|
||||
accessToken={appInfo.site?.access_token}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
<CustomizeModal
|
||||
isShow={showCustomizeModal}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -8,6 +10,11 @@ import { basePath } from '@/utils/var'
|
||||
type OverviewCardType = 'api' | 'webapp'
|
||||
|
||||
export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
|
||||
export type WorkflowLaunchInputValue = string | boolean
|
||||
export type WorkflowHiddenStartVariable = Pick<
|
||||
InputVar,
|
||||
'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable'
|
||||
>
|
||||
|
||||
type AppInfo = AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
@ -16,6 +23,7 @@ type WorkflowLike = {
|
||||
nodes?: Array<{
|
||||
data?: {
|
||||
type?: string
|
||||
variables?: InputVar[]
|
||||
}
|
||||
}>
|
||||
}
|
||||
@ -42,10 +50,173 @@ const getCardAppMode = (mode: AppModeEnum) => {
|
||||
return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode
|
||||
}
|
||||
|
||||
const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set<InputVarType>([
|
||||
InputVarType.textInput,
|
||||
InputVarType.paragraph,
|
||||
InputVarType.select,
|
||||
InputVarType.number,
|
||||
InputVarType.checkbox,
|
||||
InputVarType.json,
|
||||
InputVarType.jsonObject,
|
||||
InputVarType.url,
|
||||
])
|
||||
|
||||
const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => {
|
||||
if (variable.type === InputVarType.checkbox) {
|
||||
if (typeof variable.default === 'boolean')
|
||||
return variable.default
|
||||
|
||||
return String(variable.default).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
if (typeof variable.default === 'number')
|
||||
return String(variable.default)
|
||||
|
||||
return String(variable.default ?? '')
|
||||
}
|
||||
|
||||
export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => {
|
||||
return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false
|
||||
}
|
||||
|
||||
export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => {
|
||||
const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start)
|
||||
return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true)
|
||||
}
|
||||
|
||||
export const getAppHiddenLaunchVariables = ({
|
||||
appInfo,
|
||||
currentWorkflow,
|
||||
}: {
|
||||
appInfo: AppInfo
|
||||
currentWorkflow: WorkflowLike
|
||||
}) => {
|
||||
if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode))
|
||||
return getWorkflowHiddenStartVariables(currentWorkflow)
|
||||
}
|
||||
|
||||
export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => {
|
||||
return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type)
|
||||
}
|
||||
|
||||
export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => {
|
||||
return variables.reduce<Record<string, WorkflowLaunchInputValue>>((acc, variable) => {
|
||||
acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const buildWorkflowLaunchUrl = async ({
|
||||
accessibleUrl,
|
||||
variables,
|
||||
values,
|
||||
}: {
|
||||
accessibleUrl: string
|
||||
variables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
}) => {
|
||||
const targetUrl = new URL(accessibleUrl, window.location.origin)
|
||||
variables.forEach((variable) => {
|
||||
const rawValue = values[variable.variable]
|
||||
const serializedValue = variable.type === InputVarType.checkbox
|
||||
? String(Boolean(rawValue))
|
||||
: String(rawValue ?? '')
|
||||
|
||||
targetUrl.searchParams.set(variable.variable, serializedValue)
|
||||
})
|
||||
|
||||
return targetUrl.toString()
|
||||
}
|
||||
|
||||
export const getEmbeddedIframeSnippet = (iframeUrl: string) =>
|
||||
`<iframe
|
||||
src="${iframeUrl}"
|
||||
style="width: 100%; height: 100%; min-height: 700px"
|
||||
frameborder="0"
|
||||
allow="microphone">
|
||||
</iframe>`
|
||||
|
||||
const getScriptInputsContent = (values: Record<string, WorkflowLaunchInputValue>) => {
|
||||
const entries = Object.entries(values)
|
||||
|
||||
if (!entries.length) {
|
||||
return `{
|
||||
// You can define the inputs from the Start node here
|
||||
// key is the variable name
|
||||
// e.g.
|
||||
// name: "NAME"
|
||||
}`
|
||||
}
|
||||
|
||||
return `{
|
||||
${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')}
|
||||
}`
|
||||
}
|
||||
|
||||
export const getEmbeddedScriptSnippet = ({
|
||||
url,
|
||||
token,
|
||||
primaryColor,
|
||||
isTestEnv,
|
||||
inputValues,
|
||||
}: {
|
||||
url: string
|
||||
token: string
|
||||
primaryColor: string
|
||||
isTestEnv?: boolean
|
||||
inputValues: Record<string, WorkflowLaunchInputValue>
|
||||
}) =>
|
||||
`<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: '${token}'${isTestEnv
|
||||
? `,
|
||||
isDev: true`
|
||||
: ''}${IS_CE_EDITION
|
||||
? `,
|
||||
baseUrl: '${url}${basePath}'`
|
||||
: ''},
|
||||
inputs: ${getScriptInputsContent(inputValues)},
|
||||
systemVariables: {
|
||||
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
||||
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
||||
},
|
||||
userVariables: {
|
||||
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
src="${url}${basePath}/embed.min.js"
|
||||
id="${token}"
|
||||
defer>
|
||||
</script>
|
||||
<style>
|
||||
#dify-chatbot-bubble-button {
|
||||
background-color: ${primaryColor} !important;
|
||||
}
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 24rem !important;
|
||||
height: 40rem !important;
|
||||
}
|
||||
</style>`
|
||||
|
||||
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
|
||||
|
||||
export const compressAndEncodeBase64 = async (input: string) => {
|
||||
const uint8Array = new TextEncoder().encode(input)
|
||||
if (typeof CompressionStream === 'undefined')
|
||||
return btoa(String.fromCharCode(...uint8Array))
|
||||
|
||||
const compressedStream = new Response(
|
||||
new Blob([uint8Array])
|
||||
.stream()
|
||||
.pipeThrough(new CompressionStream('gzip')),
|
||||
).arrayBuffer()
|
||||
const compressedUint8Array = new Uint8Array(await compressedStream)
|
||||
return btoa(String.fromCharCode(...compressedUint8Array))
|
||||
}
|
||||
|
||||
export const getAppCardDisplayState = ({
|
||||
appInfo,
|
||||
cardType,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import type { WorkflowLaunchInputValue } from './app-card-utils'
|
||||
import type { ConfigParams } from './settings'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
@ -28,11 +29,16 @@ import {
|
||||
AppCardOperations,
|
||||
AppCardUrlSection,
|
||||
createAppCardOperations,
|
||||
WorkflowLaunchDialog,
|
||||
} from './app-card-sections'
|
||||
import {
|
||||
buildWorkflowLaunchUrl,
|
||||
createWorkflowLaunchInitialValues,
|
||||
getAppCardDisplayState,
|
||||
getAppCardOperationKeys,
|
||||
getAppHiddenLaunchVariables,
|
||||
isAppAccessConfigured,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from './app-card-utils'
|
||||
|
||||
export type IAppCardProps = {
|
||||
@ -63,7 +69,8 @@ function AppCard({
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
|
||||
const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
|
||||
const docLink = useDocLink()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
@ -73,6 +80,8 @@ function AppCard({
|
||||
const [genLoading, setGenLoading] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false)
|
||||
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appAccessSubjects } = useAppWhiteListSubjects(
|
||||
@ -98,6 +107,25 @@ function AppCard({
|
||||
() => isAppAccessConfigured(appDetail, appAccessSubjects),
|
||||
[appAccessSubjects, appDetail],
|
||||
)
|
||||
const hiddenLaunchVariables = useMemo(
|
||||
() => getAppHiddenLaunchVariables({
|
||||
appInfo,
|
||||
currentWorkflow,
|
||||
}) || [],
|
||||
[appInfo, currentWorkflow],
|
||||
)
|
||||
const supportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const unsupportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const initialWorkflowLaunchValues = useMemo(
|
||||
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
|
||||
[supportedWorkflowLaunchVariables],
|
||||
)
|
||||
|
||||
const onGenCode = async () => {
|
||||
if (!onGenerateCode)
|
||||
@ -139,6 +167,31 @@ function AppCard({
|
||||
window.open(cardState.accessibleUrl, '_blank')
|
||||
}, [cardState.accessibleUrl])
|
||||
|
||||
const handleOpenWorkflowLaunchDialog = useCallback(() => {
|
||||
setWorkflowLaunchValues(initialWorkflowLaunchValues)
|
||||
setShowWorkflowLaunchDialog(true)
|
||||
}, [initialWorkflowLaunchValues])
|
||||
|
||||
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
|
||||
setWorkflowLaunchValues(prev => ({
|
||||
...prev,
|
||||
[variable]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const targetUrl = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: cardState.accessibleUrl,
|
||||
variables: supportedWorkflowLaunchVariables,
|
||||
values: workflowLaunchValues,
|
||||
})
|
||||
|
||||
window.open(targetUrl, '_blank')
|
||||
setShowWorkflowLaunchDialog(false)
|
||||
}, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues])
|
||||
|
||||
const handleOpenCustomize = useCallback(() => {
|
||||
setShowCustomizeModal(true)
|
||||
}, [])
|
||||
@ -304,7 +357,17 @@ function AppCard({
|
||||
{!cardState.isMinimalState && (
|
||||
<div className="flex items-center gap-1 self-stretch p-3">
|
||||
{!isApp && <SecretKeyButton appId={appInfo.id} />}
|
||||
<AppCardOperations t={t} operations={operations} />
|
||||
<AppCardOperations
|
||||
t={t}
|
||||
operations={operations}
|
||||
launchConfigAction={hiddenLaunchVariables.length > 0
|
||||
? {
|
||||
label: t('operation.config', { ns: 'common' }),
|
||||
disabled: triggerModeDisabled || !cardState.runningStatus,
|
||||
onClick: handleOpenWorkflowLaunchDialog,
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -323,6 +386,17 @@ function AppCard({
|
||||
onCloseAccessControl={() => setShowAccessControl(false)}
|
||||
onSaveSiteConfig={onSaveSiteConfig}
|
||||
onConfirmAccessControl={handleAccessControlUpdate}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
<WorkflowLaunchDialog
|
||||
t={t}
|
||||
open={showWorkflowLaunchDialog}
|
||||
hiddenVariables={supportedWorkflowLaunchVariables}
|
||||
unsupportedVariables={unsupportedWorkflowLaunchVariables}
|
||||
values={workflowLaunchValues}
|
||||
onOpenChange={setShowWorkflowLaunchDialog}
|
||||
onValueChange={handleWorkflowLaunchValueChange}
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
|
||||
import { act } from 'react'
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import Embedded from '../index'
|
||||
|
||||
vi.mock('../style.module.css', () => ({
|
||||
@ -46,6 +47,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}))
|
||||
const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const mockedCopy = vi.mocked(copy)
|
||||
const originalCompressionStream = globalThis.CompressionStream
|
||||
|
||||
const siteInfo: SiteInfo = {
|
||||
title: 'test site',
|
||||
@ -70,6 +72,22 @@ const getCopyButton = () => {
|
||||
}
|
||||
|
||||
describe('Embedded', () => {
|
||||
beforeAll(() => {
|
||||
class MockCompressionStream {
|
||||
readable: ReadableStream<Uint8Array>
|
||||
writable: WritableStream<Uint8Array>
|
||||
|
||||
constructor() {
|
||||
const transformStream = new TransformStream<Uint8Array, Uint8Array>()
|
||||
this.readable = transformStream.readable
|
||||
this.writable = transformStream.writable
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error test polyfill
|
||||
globalThis.CompressionStream = MockCompressionStream
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWindowOpen.mockClear()
|
||||
@ -77,6 +95,7 @@ describe('Embedded', () => {
|
||||
|
||||
afterAll(() => {
|
||||
mockWindowOpen.mockRestore()
|
||||
globalThis.CompressionStream = originalCompressionStream
|
||||
})
|
||||
|
||||
it('builds theme and copies iframe snippet', async () => {
|
||||
@ -84,14 +103,20 @@ describe('Embedded', () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
const innerDiv = actionButton.querySelector('div')
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted)
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
})
|
||||
})
|
||||
|
||||
it('opens chrome plugin store link when chrome option selected', async () => {
|
||||
@ -116,4 +141,106 @@ describe('Embedded', () => {
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => {
|
||||
render(
|
||||
<Embedded
|
||||
{...baseProps}
|
||||
hiddenInputs={[{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Secret')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText('Secret'), {
|
||||
target: { value: 'top-secret' },
|
||||
})
|
||||
})
|
||||
|
||||
expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token')
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D')
|
||||
})
|
||||
|
||||
const optionButtons = document.body.querySelectorAll('[class*="option"]')
|
||||
act(() => {
|
||||
fireEvent.click(optionButtons[1]!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"')
|
||||
})
|
||||
})
|
||||
|
||||
it('copies script content when scripts option is selected', async () => {
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
|
||||
const optionButtons = document.body.querySelectorAll('[class*="option"]')
|
||||
act(() => {
|
||||
fireEvent.click(optionButtons[1]!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('token: \'token\'')
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
const innerDiv = actionButton.querySelector('div')
|
||||
await act(async () => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\''))
|
||||
})
|
||||
})
|
||||
|
||||
it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => {
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
|
||||
const optionButtons = document.body.querySelectorAll('[class*="option"]')
|
||||
act(() => {
|
||||
fireEvent.click(optionButtons[2]!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:')
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
const innerDiv = actionButton.querySelector('div')
|
||||
await act(async () => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,88 +1,46 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { basePath } from '@/utils/var'
|
||||
import {
|
||||
compressAndEncodeBase64,
|
||||
createWorkflowLaunchInitialValues,
|
||||
getChromePluginContent,
|
||||
getEmbeddedIframeSnippet,
|
||||
getEmbeddedScriptSnippet,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from '../app-card-utils'
|
||||
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
|
||||
import style from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
siteInfo?: SiteInfo
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
accessToken: string
|
||||
appBaseUrl: string
|
||||
accessToken?: string
|
||||
appBaseUrl?: string
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const OPTION_MAP = {
|
||||
iframe: {
|
||||
getContent: (url: string, token: string) =>
|
||||
`<iframe
|
||||
src="${url}${basePath}/chatbot/${token}"
|
||||
style="width: 100%; height: 100%; min-height: 700px"
|
||||
frameborder="0"
|
||||
allow="microphone">
|
||||
</iframe>`,
|
||||
},
|
||||
scripts: {
|
||||
getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) =>
|
||||
`<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: '${token}'${isTestEnv
|
||||
? `,
|
||||
isDev: true`
|
||||
: ''}${IS_CE_EDITION
|
||||
? `,
|
||||
baseUrl: '${url}${basePath}'`
|
||||
: ''},
|
||||
inputs: {
|
||||
// You can define the inputs from the Start node here
|
||||
// key is the variable name
|
||||
// e.g.
|
||||
// name: "NAME"
|
||||
},
|
||||
systemVariables: {
|
||||
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
||||
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
||||
},
|
||||
userVariables: {
|
||||
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
src="${url}${basePath}/embed.min.js"
|
||||
id="${token}"
|
||||
defer>
|
||||
</script>
|
||||
<style>
|
||||
#dify-chatbot-bubble-button {
|
||||
background-color: ${primaryColor} !important;
|
||||
}
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 24rem !important;
|
||||
height: 40rem !important;
|
||||
}
|
||||
</style>`,
|
||||
},
|
||||
chromePlugin: {
|
||||
getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`,
|
||||
},
|
||||
}
|
||||
const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
|
||||
const prefixEmbedded = 'overview.appInfo.embedded'
|
||||
|
||||
type Option = keyof typeof OPTION_MAP
|
||||
|
||||
const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin']
|
||||
type Option = typeof OPTION_KEYS[number]
|
||||
|
||||
const optionIconClassName: Record<Option, string> = {
|
||||
iframe: style.iframeIcon!,
|
||||
@ -90,38 +48,274 @@ const optionIconClassName: Record<Option, string> = {
|
||||
chromePlugin: style.chromePluginIcon!,
|
||||
}
|
||||
|
||||
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => {
|
||||
const getSerializedHiddenInputValue = (
|
||||
variable: WorkflowHiddenStartVariable,
|
||||
values: Record<string, WorkflowLaunchInputValue>,
|
||||
) => {
|
||||
const rawValue = values[variable.variable]
|
||||
if (variable.type === InputVarType.checkbox)
|
||||
return String(Boolean(rawValue))
|
||||
|
||||
return String(rawValue ?? '')
|
||||
}
|
||||
|
||||
const buildEmbeddedIframeUrl = async ({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables,
|
||||
values,
|
||||
}: {
|
||||
appBaseUrl: string
|
||||
accessToken: string
|
||||
variables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
}) => {
|
||||
const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin)
|
||||
|
||||
await Promise.all(variables.map(async (variable) => {
|
||||
iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values)))
|
||||
}))
|
||||
|
||||
return iframeUrl.toString()
|
||||
}
|
||||
|
||||
const AsyncEmbeddedOptionContent = ({
|
||||
option,
|
||||
iframeUrlPromise,
|
||||
latestResolvedIframeUrlRef,
|
||||
}: {
|
||||
option: Option
|
||||
iframeUrlPromise: Promise<string>
|
||||
latestResolvedIframeUrlRef: MutableRefObject<string>
|
||||
}) => {
|
||||
const iframeUrl = use(iframeUrlPromise)
|
||||
latestResolvedIframeUrlRef.current = iframeUrl
|
||||
|
||||
if (option === 'chromePlugin')
|
||||
return getChromePluginContent(iframeUrl)
|
||||
|
||||
return getEmbeddedIframeSnippet(iframeUrl)
|
||||
}
|
||||
|
||||
const EmbeddedContent = ({
|
||||
siteInfo,
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
hiddenInputs,
|
||||
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'hiddenInputs'>) => {
|
||||
const { t } = useTranslation()
|
||||
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
|
||||
[hiddenInputs],
|
||||
)
|
||||
const initialHiddenInputValues = useMemo(
|
||||
() => createWorkflowLaunchInitialValues(supportedHiddenInputs),
|
||||
[supportedHiddenInputs],
|
||||
)
|
||||
const [option, setOption] = useState<Option>('iframe')
|
||||
const [copiedOption, setCopiedOption] = useState<Option | null>(null)
|
||||
const [hiddenInputsCollapsed, setHiddenInputsCollapsed] = useState(true)
|
||||
const [hiddenInputValues, setHiddenInputValues] = useState<Record<string, WorkflowLaunchInputValue>>(
|
||||
() => initialHiddenInputValues,
|
||||
)
|
||||
const [previewIframeUrlPromise, setPreviewIframeUrlPromise] = useState<Promise<string>>(
|
||||
() => buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables: supportedHiddenInputs,
|
||||
values: initialHiddenInputValues,
|
||||
}),
|
||||
)
|
||||
const latestResolvedIframeUrlRef = useRef('')
|
||||
|
||||
const { langGeniusVersionInfo } = useAppContext()
|
||||
const themeBuilder = useThemeContext()
|
||||
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
|
||||
const isTestEnv = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
const onClickCopy = () => {
|
||||
|
||||
const handleHiddenInputValueChange = (variable: string, value: WorkflowLaunchInputValue) => {
|
||||
const nextHiddenInputValues = {
|
||||
...hiddenInputValues,
|
||||
[variable]: value,
|
||||
}
|
||||
|
||||
setCopiedOption(null)
|
||||
setHiddenInputValues(nextHiddenInputValues)
|
||||
setPreviewIframeUrlPromise(buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables: supportedHiddenInputs,
|
||||
values: nextHiddenInputValues,
|
||||
}))
|
||||
}
|
||||
const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({
|
||||
url: appBaseUrl,
|
||||
token: accessToken,
|
||||
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
|
||||
isTestEnv,
|
||||
inputValues: hiddenInputValues,
|
||||
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor])
|
||||
|
||||
const onClickCopy = async () => {
|
||||
const latestIframeUrl = await buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables: supportedHiddenInputs,
|
||||
values: hiddenInputValues,
|
||||
})
|
||||
|
||||
if (option === 'chromePlugin') {
|
||||
const splitUrl = OPTION_MAP[option].getContent(appBaseUrl, accessToken).split(': ')
|
||||
const splitUrl = getChromePluginContent(latestIframeUrl).split(': ')
|
||||
if (splitUrl.length > 1)
|
||||
copy(splitUrl[1]!)
|
||||
}
|
||||
else if (option === 'iframe') {
|
||||
copy(getEmbeddedIframeSnippet(latestIframeUrl))
|
||||
}
|
||||
else {
|
||||
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv))
|
||||
copy(scriptsContent)
|
||||
}
|
||||
setCopiedOption(option)
|
||||
}
|
||||
const previewFallback = latestResolvedIframeUrlRef.current
|
||||
? (option === 'chromePlugin'
|
||||
? getChromePluginContent(latestResolvedIframeUrlRef.current)
|
||||
: getEmbeddedIframeSnippet(latestResolvedIframeUrlRef.current))
|
||||
: ''
|
||||
|
||||
const navigateToChromeUrl = () => {
|
||||
window.open('https://chrome.google.com/webstore/detail/dify-chatbot/ceehdapohffmjmkdcifjofadiaoeggaf', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
|
||||
}, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
{supportedHiddenInputs.length > 0 && (
|
||||
<div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||||
onClick={() => setHiddenInputsCollapsed(prev => !prev)}
|
||||
>
|
||||
<div>
|
||||
<div className="system-sm-medium text-text-primary">
|
||||
{t(`${prefixEmbedded}.hiddenInputs.title`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(`${prefixEmbedded}.hiddenInputs.description`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
</div>
|
||||
{hiddenInputsCollapsed
|
||||
? <RiArrowRightSLine className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
: <RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
|
||||
</button>
|
||||
{!hiddenInputsCollapsed && (
|
||||
<div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4">
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={supportedHiddenInputs}
|
||||
values={hiddenInputValues}
|
||||
onValueChange={handleHiddenInputValueChange}
|
||||
fieldIdPrefix="embedded-hidden-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
{OPTION_KEYS.map((v) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
aria-label={t(`${prefixEmbedded}.${v}`, { ns: 'appOverview' }) || v}
|
||||
className={cn(
|
||||
style.option,
|
||||
optionIconClassName[v],
|
||||
option === v && style.active,
|
||||
)}
|
||||
onClick={() => {
|
||||
setOption(v)
|
||||
setCopiedOption(null)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{option === 'chromePlugin' && (
|
||||
<div className="mt-6 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')}
|
||||
onClick={navigateToChromeUrl}
|
||||
>
|
||||
<div className={`relative h-4 w-4 ${style.pluginInstallIcon}`}></div>
|
||||
<div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t(`${prefixEmbedded}.chromePlugin`, { ns: 'appOverview' })}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}>
|
||||
<div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3">
|
||||
<div className="shrink-0 grow system-sm-medium text-text-secondary">
|
||||
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
aria-label={(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
onClick={() => void onClickCopy()}
|
||||
>
|
||||
{copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill h-4 w-4" />}
|
||||
{copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3">
|
||||
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
|
||||
<pre className="select-text">
|
||||
{option === 'scripts'
|
||||
? scriptsContent
|
||||
: (
|
||||
<Suspense fallback={previewFallback}>
|
||||
<AsyncEmbeddedOptionContent
|
||||
option={option}
|
||||
iframeUrlPromise={previewIframeUrlPromise}
|
||||
latestResolvedIframeUrlRef={latestResolvedIframeUrlRef}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (open)
|
||||
return
|
||||
setCopiedOption(null)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
@ -130,73 +324,16 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
|
||||
{t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton />
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
{OPTIONS.map((v) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
aria-label={t(`${prefixEmbedded}.${v}`, { ns: 'appOverview' }) || v}
|
||||
className={cn(
|
||||
style.option,
|
||||
optionIconClassName[v],
|
||||
option === v && style.active,
|
||||
)}
|
||||
onClick={() => {
|
||||
setOption(v)
|
||||
setCopiedOption(null)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{option === 'chromePlugin' && (
|
||||
<div className="mt-6 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')}
|
||||
onClick={navigateToChromeUrl}
|
||||
>
|
||||
<div className={`relative h-4 w-4 ${style.pluginInstallIcon}`}></div>
|
||||
<div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t(`${prefixEmbedded}.chromePlugin`, { ns: 'appOverview' })}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}>
|
||||
<div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3">
|
||||
<div className="shrink-0 grow system-sm-medium text-text-secondary">
|
||||
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
aria-label={(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
onClick={onClickCopy}
|
||||
>
|
||||
{copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill h-4 w-4" />}
|
||||
{copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3">
|
||||
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
|
||||
<pre className="select-text">{OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(90vh-88px)] overflow-y-auto">
|
||||
{isShow && (
|
||||
<EmbeddedContent
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
siteInfo={siteInfo}
|
||||
appBaseUrl={appBaseUrl ?? ''}
|
||||
accessToken={accessToken ?? ''}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
116
web/app/components/app/overview/workflow-hidden-input-fields.tsx
Normal file
116
web/app/components/app/overview/workflow-hidden-input-fields.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from './app-card-utils'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
type WorkflowHiddenInputFieldsProps = {
|
||||
hiddenVariables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
|
||||
fieldIdPrefix?: string
|
||||
}
|
||||
|
||||
const WorkflowHiddenInputFields = ({
|
||||
hiddenVariables,
|
||||
values,
|
||||
onValueChange,
|
||||
fieldIdPrefix = 'workflow-launch-hidden-input',
|
||||
}: WorkflowHiddenInputFieldsProps) => {
|
||||
const renderField = (variable: WorkflowHiddenStartVariable) => {
|
||||
const fieldId = `${fieldIdPrefix}-${variable.variable}`
|
||||
const fieldValue = values[variable.variable]
|
||||
const label = typeof variable.label === 'string' ? variable.label : variable.variable
|
||||
|
||||
if (variable.type === InputVarType.select) {
|
||||
return (
|
||||
<Select
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onValueChange={value => onValueChange(variable.variable, value ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={label}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(variable.options ?? []).map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (variable.type === InputVarType.checkbox) {
|
||||
return (
|
||||
<label className="flex min-h-10 w-full cursor-pointer items-center gap-3 rounded-lg bg-components-input-bg-normal px-3 py-2">
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
checked={Boolean(fieldValue)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.checked)}
|
||||
className="h-4 w-4 rounded border-divider-subtle"
|
||||
/>
|
||||
<span className="system-sm-regular text-text-secondary">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
variable.type === InputVarType.paragraph
|
||||
|| variable.type === InputVarType.json
|
||||
|| variable.type === InputVarType.jsonObject
|
||||
) {
|
||||
return (
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
|
||||
placeholder={label}
|
||||
maxLength={variable.max_length}
|
||||
className="min-h-24"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={fieldId}
|
||||
type={variable.type === InputVarType.number ? 'number' : 'text'}
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.value)}
|
||||
placeholder={label}
|
||||
maxLength={variable.max_length}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hiddenVariables.map(variable => (
|
||||
<div key={variable.variable} className="space-y-1.5">
|
||||
{variable.type !== InputVarType.checkbox && (
|
||||
<label
|
||||
htmlFor={`${fieldIdPrefix}-${variable.variable}`}
|
||||
className="block system-sm-medium text-text-secondary"
|
||||
>
|
||||
{typeof variable.label === 'string' ? variable.label : variable.variable}
|
||||
</label>
|
||||
)}
|
||||
{renderField(variable)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowHiddenInputFields
|
||||
@ -31,7 +31,7 @@ const mockFetchAppDetail = vi.mocked(fetchAppDetail)
|
||||
|
||||
const mockTemplateApp: App = {
|
||||
app_id: 'template-1',
|
||||
category: 'Assistant',
|
||||
categories: ['Assistant'],
|
||||
app: {
|
||||
id: 'template-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
|
||||
@ -151,7 +151,7 @@ const Apps = () => {
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
app={currentTryAppParams?.app}
|
||||
category={currentTryAppParams?.app?.category}
|
||||
categories={currentTryAppParams?.app?.categories}
|
||||
onClose={hideTryAppPanel}
|
||||
onCreate={handleShowFromTryApp}
|
||||
/>
|
||||
|
||||
@ -321,47 +321,86 @@ describe('chat utils - url params and answer helpers', () => {
|
||||
expect(res).toEqual({ custom: '123', encoded: 'a b' })
|
||||
})
|
||||
|
||||
it('getRawInputsFromUrlParams keeps encoded launch params as decoded plain values', async () => {
|
||||
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getRawInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: 'YWJjZA==' })
|
||||
})
|
||||
|
||||
it('getRawUserVariablesFromUrlParams extracts only user. prefixed params', async () => {
|
||||
setSearch('?custom=123&sys.param=456&user.param=789&user.encoded=a%20b')
|
||||
const res = await getRawUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: '789', encoded: 'a b' })
|
||||
})
|
||||
|
||||
it('getRawUserVariablesFromUrlParams keeps encoded user values as decoded plain values', async () => {
|
||||
setSearch(`?user.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getRawUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'YWJjZA==' })
|
||||
})
|
||||
|
||||
it('getProcessedInputsFromUrlParams decompresses base64 inputs', async () => {
|
||||
setSearch('?custom=123&sys.param=456&user.param=789')
|
||||
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}&sys.param=456&user.param=789`)
|
||||
const res = await getProcessedInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedInputsFromUrlParams returns undefined for plain decoded values', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?custom=a%20b')
|
||||
const res = await getProcessedInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: undefined })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams decompresses sys. prefixed params', async () => {
|
||||
setSearch('?custom=123&sys.param=456&user.param=789')
|
||||
setSearch(`?custom=123&sys.param=${encodeURIComponent('YWJjZA==')}&user.param=789`)
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams returns undefined for plain decoded values', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?sys.param=a%20b')
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: undefined })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams parses redirect_url without query string', async () => {
|
||||
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=456`)
|
||||
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams parses redirect_url', async () => {
|
||||
setSearch(`?redirect_url=${encodeURIComponent('http://example.com?sys.redirected=abc')}&sys.param=456`)
|
||||
setSearch(`?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text', redirected: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedUserVariablesFromUrlParams decompresses user. prefixed params', async () => {
|
||||
setSearch('?custom=123&sys.param=456&user.param=789')
|
||||
setSearch(`?custom=123&sys.param=456&user.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getProcessedUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedUserVariablesFromUrlParams returns undefined for plain decoded values', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?user.param=a%20b')
|
||||
const res = await getProcessedUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: undefined })
|
||||
})
|
||||
|
||||
it('decodeBase64AndDecompress failure returns undefined softly', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?custom=invalid_base64')
|
||||
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getProcessedInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: undefined })
|
||||
})
|
||||
|
||||
@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={<div />}
|
||||
render={(
|
||||
<ActionButton
|
||||
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
state={
|
||||
isActive
|
||||
? ActionButtonState.Active
|
||||
: open
|
||||
? ActionButtonState.Hover
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ActionButton
|
||||
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
state={
|
||||
isActive
|
||||
? ActionButtonState.Active
|
||||
: open
|
||||
? ActionButtonState.Hover
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -182,11 +182,13 @@ describe('useChatLayout', () => {
|
||||
|
||||
act(() => {
|
||||
capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver)
|
||||
flushAnimationFrames()
|
||||
})
|
||||
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
|
||||
|
||||
act(() => {
|
||||
capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver)
|
||||
flushAnimationFrames()
|
||||
})
|
||||
expect(screen.getByTestId('chat-footer').style.width).toBe('560px')
|
||||
|
||||
|
||||
@ -12,6 +12,11 @@ type UseChatLayoutOptions = {
|
||||
sidebarCollapseState?: boolean
|
||||
}
|
||||
|
||||
const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => {
|
||||
if (element.style[property] !== value)
|
||||
element.style[property] = value
|
||||
}
|
||||
|
||||
export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => {
|
||||
const [width, setWidth] = useState(0)
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
@ -21,6 +26,9 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
const userScrolledRef = useRef(false)
|
||||
const isAutoScrollingRef = useRef(false)
|
||||
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
|
||||
const resizeObserverFrameRef = useRef<number | null>(null)
|
||||
const pendingFooterBlockSizeRef = useRef<number | null>(null)
|
||||
const pendingContainerInlineSizeRef = useRef<number | null>(null)
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) {
|
||||
@ -34,16 +42,39 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
}, [chatList.length])
|
||||
|
||||
const handleWindowResize = useCallback(() => {
|
||||
if (chatContainerRef.current)
|
||||
setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8)
|
||||
if (chatContainerRef.current) {
|
||||
const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8
|
||||
setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth)
|
||||
}
|
||||
|
||||
if (chatContainerRef.current && chatFooterRef.current)
|
||||
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
|
||||
setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`)
|
||||
|
||||
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
|
||||
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
|
||||
setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`)
|
||||
}, [])
|
||||
|
||||
const scheduleResizeObserverUpdate = useCallback(() => {
|
||||
if (resizeObserverFrameRef.current !== null)
|
||||
return
|
||||
|
||||
resizeObserverFrameRef.current = requestAnimationFrame(() => {
|
||||
resizeObserverFrameRef.current = null
|
||||
|
||||
const footerBlockSize = pendingFooterBlockSizeRef.current
|
||||
pendingFooterBlockSizeRef.current = null
|
||||
if (footerBlockSize !== null && chatContainerRef.current) {
|
||||
setStyleValue(chatContainerRef.current, 'paddingBottom', `${footerBlockSize}px`)
|
||||
handleScrollToBottom()
|
||||
}
|
||||
|
||||
const containerInlineSize = pendingContainerInlineSizeRef.current
|
||||
pendingContainerInlineSizeRef.current = null
|
||||
if (containerInlineSize !== null && chatFooterRef.current)
|
||||
setStyleValue(chatFooterRef.current, 'width', `${containerInlineSize}px`)
|
||||
})
|
||||
}, [handleScrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
handleScrollToBottom()
|
||||
const animationFrame = requestAnimationFrame(handleWindowResize)
|
||||
@ -77,26 +108,31 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
const resizeContainerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { blockSize } = entry.borderBoxSize[0]!
|
||||
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
|
||||
handleScrollToBottom()
|
||||
pendingFooterBlockSizeRef.current = blockSize
|
||||
}
|
||||
scheduleResizeObserverUpdate()
|
||||
})
|
||||
resizeContainerObserver.observe(chatFooterRef.current)
|
||||
|
||||
const resizeFooterObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { inlineSize } = entry.borderBoxSize[0]!
|
||||
chatFooterRef.current!.style.width = `${inlineSize}px`
|
||||
pendingContainerInlineSizeRef.current = inlineSize
|
||||
}
|
||||
scheduleResizeObserverUpdate()
|
||||
})
|
||||
resizeFooterObserver.observe(chatContainerRef.current)
|
||||
|
||||
return () => {
|
||||
if (resizeObserverFrameRef.current !== null) {
|
||||
cancelAnimationFrame(resizeObserverFrameRef.current)
|
||||
resizeObserverFrameRef.current = null
|
||||
}
|
||||
resizeContainerObserver.disconnect()
|
||||
resizeFooterObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [handleScrollToBottom])
|
||||
}, [scheduleResizeObserverUpdate])
|
||||
|
||||
useEffect(() => {
|
||||
const setUserScrolled = () => {
|
||||
|
||||
@ -19,11 +19,13 @@ async function getRawInputsFromUrlParams(): Promise<Record<string, any>> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inputs: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
entriesArray.forEach(([key, value]) => {
|
||||
await Promise.all(entriesArray.map(async ([key, value]) => {
|
||||
const prefixArray = ['sys.', 'user.']
|
||||
if (!prefixArray.some(prefix => key.startsWith(prefix)))
|
||||
inputs[key] = decodeURIComponent(value)
|
||||
})
|
||||
if (prefixArray.some(prefix => key.startsWith(prefix)))
|
||||
return
|
||||
|
||||
inputs[key] = decodeURIComponent(value)
|
||||
}))
|
||||
return inputs
|
||||
}
|
||||
|
||||
@ -81,10 +83,12 @@ async function getRawUserVariablesFromUrlParams(): Promise<Record<string, any>>
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const userVariables: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
entriesArray.forEach(([key, value]) => {
|
||||
if (key.startsWith('user.'))
|
||||
userVariables[key.slice(5)] = decodeURIComponent(value)
|
||||
})
|
||||
await Promise.all(entriesArray.map(async ([key, value]) => {
|
||||
if (!key.startsWith('user.'))
|
||||
return
|
||||
|
||||
userVariables[key.slice(5)] = decodeURIComponent(value)
|
||||
}))
|
||||
return userVariables
|
||||
}
|
||||
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export { default as BracketsX } from './BracketsX'
|
||||
export { default as CodeBrowser } from './CodeBrowser'
|
||||
|
||||
@ -120,18 +120,12 @@ vi.mock('../document-title', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../segment-add', () => ({
|
||||
default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
|
||||
SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
|
||||
<div data-testid="segment-add" data-embedding={embedding}>
|
||||
<button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button>
|
||||
<button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button>
|
||||
</div>
|
||||
),
|
||||
ProcessStatus: {
|
||||
WAITING: 'waiting',
|
||||
PROCESSING: 'processing',
|
||||
ERROR: 'error',
|
||||
COMPLETED: 'completed',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../components/operations', () => ({
|
||||
|
||||
@ -2,10 +2,14 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ChunkingMode, FileItem } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CSVDownloader from './csv-downloader'
|
||||
import CSVUploader from './csv-uploader'
|
||||
@ -17,8 +21,9 @@ type IBatchModalProps = {
|
||||
onConfirm: (file: FileItem) => void
|
||||
}
|
||||
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
isShow,
|
||||
type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'>
|
||||
|
||||
const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
docForm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@ -34,36 +39,55 @@ const BatchModal: FC<IBatchModalProps> = ({
|
||||
onConfirm(currentCSV)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow)
|
||||
setCurrentCSV(undefined)
|
||||
}, [isShow])
|
||||
|
||||
return (
|
||||
<Dialog open={isShow}>
|
||||
<DialogContent className="w-full max-w-[520px]! overflow-hidden! rounded-xl! border-none px-8 py-6 text-left align-middle">
|
||||
<DialogContent className="w-[520px]! overflow-hidden! rounded-xl! border-0! px-8 py-6">
|
||||
<DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</DialogTitle>
|
||||
<DialogCloseButton
|
||||
className="top-4 right-4"
|
||||
aria-label={t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
|
||||
/>
|
||||
<CSVUploader
|
||||
file={currentCSV}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
<CSVDownloader
|
||||
docForm={docForm}
|
||||
/>
|
||||
<div className="mt-[28px] flex justify-end pt-6">
|
||||
<Button className="mr-2" onClick={onCancel}>
|
||||
{t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSend} disabled={!currentCSV || !currentCSV.file || !currentCSV.file.id}>
|
||||
{t('list.batchModal.run', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</div>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
<CSVUploader
|
||||
file={currentCSV}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
<CSVDownloader
|
||||
docForm={docForm}
|
||||
/>
|
||||
<div className="mt-[28px] flex justify-end pt-6">
|
||||
<Button className="mr-2" onClick={onCancel}>
|
||||
{t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSend} disabled={!currentCSV || !currentCSV.file || !currentCSV.file.id}>
|
||||
{t('list.batchModal.run', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
isShow,
|
||||
docForm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={open => !open && onCancel()}
|
||||
disablePointerDismissal
|
||||
>
|
||||
{isShow
|
||||
? (
|
||||
<BatchModalContent
|
||||
docForm={docForm}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BatchModal)
|
||||
|
||||
@ -137,9 +137,8 @@ vi.mock('../hooks/use-child-segment-data', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('../components', () => ({
|
||||
MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
|
||||
vi.mock('../components/menu-bar', () => ({
|
||||
default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
|
||||
totalText: string
|
||||
onInputChange: (value: string) => void
|
||||
inputValue: string
|
||||
@ -167,7 +166,13 @@ vi.mock('../components', () => ({
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/drawer-group', () => ({
|
||||
DrawerGroup: () => <div data-testid="drawer-group" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/segment-list-content', () => ({
|
||||
FullDocModeContent: () => <div data-testid="full-doc-mode-content" />,
|
||||
GeneralModeContent: () => <div data-testid="general-mode-content" />,
|
||||
}))
|
||||
@ -563,7 +568,7 @@ describe('Edge Cases', () => {
|
||||
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ProcessStatus.COMPLETED importStatus', () => {
|
||||
it('should handle completed importStatus', () => {
|
||||
render(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
|
||||
|
||||
@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import SegmentDetail from '../segment-detail'
|
||||
import { SegmentDetail } from '../segment-detail'
|
||||
|
||||
// Mock dataset detail context
|
||||
let mockIndexingTechnique = IndexingType.QUALIFIED
|
||||
@ -167,7 +167,6 @@ describe('SegmentDetail', () => {
|
||||
onCancel: vi.fn(),
|
||||
isEditMode: false,
|
||||
docForm: ChunkingMode.text,
|
||||
onModalStateChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -352,35 +351,12 @@ describe('SegmentDetail', () => {
|
||||
expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onModalStateChange when regeneration modal opens', () => {
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should close modal when cancel is clicked', () => {
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-regeneration'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
|
||||
expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -504,22 +480,18 @@ describe('SegmentDetail', () => {
|
||||
|
||||
it('should close modal and edit drawer when close after regeneration is clicked', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onCancel={mockOnCancel}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open regeneration modal
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-regeneration'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,27 +1,16 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Drawer from '../drawer'
|
||||
import { CompletedDrawer } from '../drawer'
|
||||
|
||||
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
).BASE_UI_ANIMATIONS_DISABLED = true
|
||||
|
||||
// Mock useKeyPress: required because tests capture the registered callback
|
||||
// and invoke it directly to verify ESC key handling behavior.
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => {
|
||||
capturedKeyPressCallback = cb
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../..', () => ({
|
||||
useSegmentListContext: (selector: (state: {
|
||||
currSegment: { showModal: boolean }
|
||||
currChildChunk: { showModal: boolean }
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
currSegment: { showModal: false },
|
||||
currChildChunk: { showModal: false },
|
||||
}),
|
||||
}))
|
||||
const getOverlay = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[class]'))
|
||||
.find(element => element.className.includes('bg-background-overlay'))
|
||||
|
||||
describe('Drawer', () => {
|
||||
const defaultProps = {
|
||||
@ -31,103 +20,109 @@ describe('Drawer', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedKeyPressCallback = undefined
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should return null when open is false', () => {
|
||||
const { container } = render(
|
||||
<Drawer open={false} onClose={vi.fn()}>
|
||||
<CompletedDrawer open={false} onClose={vi.fn()}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
expect(screen.queryByText('Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in portal when open is true', () => {
|
||||
it('should render children in the drawer portal when open is true', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps}>
|
||||
<CompletedDrawer {...defaultProps}>
|
||||
<span>Drawer content</span>
|
||||
</Drawer>,
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Drawer content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dialog with role="dialog"', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Overlay visibility
|
||||
describe('Overlay', () => {
|
||||
it('should show overlay when showOverlay is true', () => {
|
||||
describe('Variant', () => {
|
||||
it('should render a panel drawer without overlay by default', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} showOverlay={true}>
|
||||
<CompletedDrawer {...defaultProps}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide overlay when showOverlay is false', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} showOverlay={false}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// aria-modal attribute
|
||||
describe('aria-modal', () => {
|
||||
it('should set aria-modal="true" when modal is true', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} modal={true}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it('should set aria-modal="false" when modal is false', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps} modal={false}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
expect(getOverlay()).toBeUndefined()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// ESC key handling
|
||||
describe('ESC Key', () => {
|
||||
it('should call onClose when ESC is pressed and drawer is open', () => {
|
||||
const onClose = vi.fn()
|
||||
it('should render a modal drawer with overlay', () => {
|
||||
render(
|
||||
<Drawer open={true} onClose={onClose}>
|
||||
<CompletedDrawer {...defaultProps} modal>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
expect(capturedKeyPressCallback).toBeDefined()
|
||||
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
|
||||
capturedKeyPressCallback!(fakeEvent)
|
||||
expect(getOverlay()).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismissal', () => {
|
||||
it('should call onClose when Escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep a panel drawer open when the underlying page is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<>
|
||||
<button type="button">Outside</button>
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>
|
||||
</>,
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep a panel drawer open when the pointer down starts inside content', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<button type="button">Inside</button>
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Inside' }))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should close a modal drawer when the overlay is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose} modal>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
fireEvent.click(getOverlay()!)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import FullScreenDrawer from '../full-screen-drawer'
|
||||
import { DocumentDetailDrawer } from '../full-screen-drawer'
|
||||
|
||||
// Mock the Drawer component since it has high complexity
|
||||
vi.mock('../drawer', () => ({
|
||||
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
|
||||
CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => {
|
||||
if (!open)
|
||||
return null
|
||||
return (
|
||||
@ -13,8 +13,6 @@ vi.mock('../drawer', () => ({
|
||||
data-testid="drawer-mock"
|
||||
data-panel-class={panelClassName}
|
||||
data-panel-content-class={panelContentClassName}
|
||||
data-show-overlay={showOverlay}
|
||||
data-need-check-chunks={needCheckChunks}
|
||||
data-modal={modal}
|
||||
>
|
||||
{children}
|
||||
@ -23,7 +21,7 @@ vi.mock('../drawer', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
describe('FullScreenDrawer', () => {
|
||||
describe('DocumentDetailDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -31,9 +29,9 @@ describe('FullScreenDrawer', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when open', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => {
|
||||
|
||||
it('should not render when closed', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => {
|
||||
|
||||
it('should render children content', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Test Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
@ -63,86 +61,46 @@ describe('FullScreenDrawer', () => {
|
||||
describe('Props', () => {
|
||||
it('should pass fullScreen=true to Drawer with full width class', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full')
|
||||
})
|
||||
|
||||
it('should pass fullScreen=false to Drawer with fixed width class', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]')
|
||||
})
|
||||
|
||||
it('should pass showOverlay prop with default true', () => {
|
||||
it('should render as non-modal by default', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass showOverlay=false when specified', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass needCheckChunks prop with default false', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass needCheckChunks=true when specified', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass modal prop with default false', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-modal')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass modal=true when specified', () => {
|
||||
it('should pass modal when specified', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false} modal>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -154,9 +112,9 @@ describe('FullScreenDrawer', () => {
|
||||
describe('Styling', () => {
|
||||
it('should apply panel content classes for non-fullScreen mode', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => {
|
||||
|
||||
it('should apply panel content classes without border for fullScreen mode', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => {
|
||||
// Arrange & Act & Assert - should not throw
|
||||
expect(() => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
const { rerender } = render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<div>Updated Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument()
|
||||
@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => {
|
||||
|
||||
it('should handle toggle between open and closed states', () => {
|
||||
const { rerender } = render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<DocumentDetailDrawer open={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
</DocumentDetailDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
|
||||
@ -1,143 +1,92 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSegmentListContext } from '..'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
|
||||
type DrawerProps = {
|
||||
type DrawerSide = 'right' | 'left' | 'bottom' | 'top'
|
||||
type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up'
|
||||
type DrawerOpenChange = NonNullable<ComponentProps<typeof Drawer>['onOpenChange']>
|
||||
|
||||
type CompletedDrawerProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
side?: 'right' | 'left' | 'bottom' | 'top'
|
||||
showOverlay?: boolean
|
||||
modal?: boolean // click outside event can pass through if modal is false
|
||||
closeOnOutsideClick?: boolean
|
||||
side?: DrawerSide
|
||||
panelClassName?: string
|
||||
panelContentClassName?: string
|
||||
needCheckChunks?: boolean
|
||||
modal?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const SIDE_POSITION_CLASS = {
|
||||
right: 'right-0',
|
||||
left: 'left-0',
|
||||
bottom: 'bottom-0',
|
||||
top: 'top-0',
|
||||
} as const
|
||||
|
||||
function containsTarget(selector: string, target: Node | null): boolean {
|
||||
const elements = document.querySelectorAll(selector)
|
||||
return Array.from(elements).some(el => el?.contains(target))
|
||||
const SIDE_TO_SWIPE_DIRECTION: Record<DrawerSide, DrawerSwipeDirection> = {
|
||||
right: 'right',
|
||||
left: 'left',
|
||||
bottom: 'down',
|
||||
top: 'up',
|
||||
}
|
||||
|
||||
function shouldReopenChunkDetail(
|
||||
isClickOnChunk: boolean,
|
||||
isClickOnChildChunk: boolean,
|
||||
segmentModalOpen: boolean,
|
||||
childChunkModalOpen: boolean,
|
||||
): boolean {
|
||||
if (segmentModalOpen && isClickOnChildChunk)
|
||||
return true
|
||||
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
|
||||
return true
|
||||
return !isClickOnChunk && !isClickOnChildChunk
|
||||
}
|
||||
const DRAWER_POPUP_CLASS_NAME = [
|
||||
'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none',
|
||||
'data-[swipe-direction=right]:h-screen data-[swipe-direction=right]:max-w-none data-[swipe-direction=right]:rounded-none data-[swipe-direction=right]:border-0',
|
||||
'data-[swipe-direction=left]:h-screen data-[swipe-direction=left]:max-w-none data-[swipe-direction=left]:rounded-none data-[swipe-direction=left]:border-0',
|
||||
'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0',
|
||||
'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0',
|
||||
].join(' ')
|
||||
|
||||
const Drawer = ({
|
||||
export function CompletedDrawer({
|
||||
open,
|
||||
onClose,
|
||||
side = 'right',
|
||||
showOverlay = true,
|
||||
modal = false,
|
||||
needCheckChunks = false,
|
||||
children,
|
||||
panelClassName,
|
||||
panelContentClassName,
|
||||
}: React.PropsWithChildren<DrawerProps>) => {
|
||||
const panelContentRef = useRef<HTMLDivElement>(null)
|
||||
const currSegment = useSegmentListContext(s => s.currSegment)
|
||||
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
|
||||
|
||||
useKeyPress('esc', (e) => {
|
||||
if (!open)
|
||||
modal = false,
|
||||
}: CompletedDrawerProps) {
|
||||
const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
|
||||
if (nextOpen)
|
||||
return
|
||||
e.preventDefault()
|
||||
|
||||
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
|
||||
return
|
||||
|
||||
onClose()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
const shouldCloseDrawer = useCallback((target: Node | null) => {
|
||||
const panelContent = panelContentRef.current
|
||||
if (!panelContent || !target)
|
||||
return false
|
||||
|
||||
if (panelContent.contains(target))
|
||||
return false
|
||||
|
||||
if (containsTarget('.image-previewer', target))
|
||||
return false
|
||||
|
||||
if (!needCheckChunks)
|
||||
return true
|
||||
|
||||
const isClickOnChunk = containsTarget('.chunk-card', target)
|
||||
const isClickOnChildChunk = containsTarget('.child-chunk', target)
|
||||
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
|
||||
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
|
||||
|
||||
const onDownCapture = useCallback((e: PointerEvent) => {
|
||||
if (!open || modal)
|
||||
return
|
||||
const panelContent = panelContentRef.current
|
||||
if (!panelContent)
|
||||
return
|
||||
const target = e.target as Node | null
|
||||
if (shouldCloseDrawer(target))
|
||||
queueMicrotask(onClose)
|
||||
}, [shouldCloseDrawer, onClose, open, modal])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointerdown', onDownCapture, { capture: true })
|
||||
return () =>
|
||||
window.removeEventListener('pointerdown', onDownCapture, { capture: true })
|
||||
}, [onDownCapture])
|
||||
|
||||
const isHorizontal = side === 'left' || side === 'right'
|
||||
|
||||
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
|
||||
const content = (
|
||||
<div className="pointer-events-none fixed inset-0 z-9999">
|
||||
{showOverlay && (
|
||||
<div
|
||||
onClick={modal ? onClose : undefined}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
|
||||
open && 'opacity-100',
|
||||
overlayPointerEvents,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal={modal ? 'true' : 'false'}
|
||||
className={cn(
|
||||
'pointer-events-auto fixed flex flex-col',
|
||||
SIDE_POSITION_CLASS[side],
|
||||
isHorizontal ? 'h-screen' : 'w-screen',
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return createPortal(content, document.body)
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
modal={modal}
|
||||
swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]}
|
||||
disablePointerDismissal
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DrawerPortal>
|
||||
{modal && (
|
||||
<DrawerBackdrop
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<DrawerViewport className="pointer-events-none">
|
||||
<DrawerPopup
|
||||
aria-modal={modal ? 'true' : 'false'}
|
||||
className={cn(DRAWER_POPUP_CLASS_NAME, panelClassName)}
|
||||
>
|
||||
<DrawerContent
|
||||
className={cn('flex grow flex-col overflow-visible p-0 pb-0', panelContentClassName)}
|
||||
>
|
||||
{children}
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Drawer
|
||||
|
||||
@ -1,46 +1,39 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import Drawer from './drawer'
|
||||
import { CompletedDrawer } from './drawer'
|
||||
|
||||
type IFullScreenDrawerProps = {
|
||||
isOpen: boolean
|
||||
type DocumentDetailDrawerProps = {
|
||||
open: boolean
|
||||
onClose?: () => void
|
||||
fullScreen: boolean
|
||||
showOverlay?: boolean
|
||||
needCheckChunks?: boolean
|
||||
modal?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const FullScreenDrawer = ({
|
||||
isOpen,
|
||||
export function DocumentDetailDrawer({
|
||||
open,
|
||||
onClose = noop,
|
||||
fullScreen,
|
||||
children,
|
||||
showOverlay = true,
|
||||
needCheckChunks = false,
|
||||
modal = false,
|
||||
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
|
||||
}: DocumentDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
<CompletedDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
panelClassName={cn(
|
||||
fullScreen
|
||||
? 'w-full'
|
||||
: 'w-[568px] pt-16 pr-2 pb-2',
|
||||
? 'w-full data-[swipe-direction=left]:w-full data-[swipe-direction=right]:w-full'
|
||||
: 'w-[568px] pt-16 pr-2 pb-2 data-[swipe-direction=left]:w-[568px] data-[swipe-direction=right]:w-[568px]',
|
||||
)}
|
||||
panelContentClassName={cn(
|
||||
'bg-components-panel-bg',
|
||||
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
showOverlay={showOverlay}
|
||||
needCheckChunks={needCheckChunks}
|
||||
modal={modal}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
</CompletedDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenDrawer
|
||||
|
||||
@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import DrawerGroup from '../drawer-group'
|
||||
import { DrawerGroup } from '../drawer-group'
|
||||
|
||||
vi.mock('../../common/full-screen-drawer', () => ({
|
||||
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => (
|
||||
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null
|
||||
DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => (
|
||||
open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../segment-detail', () => ({
|
||||
default: () => <div data-testid="segment-detail" />,
|
||||
SegmentDetail: () => <div data-testid="segment-detail" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../child-segment-detail', () => ({
|
||||
@ -31,8 +31,6 @@ describe('DrawerGroup', () => {
|
||||
currSegment: { segInfo: undefined, showModal: false, isEditMode: false },
|
||||
onCloseSegmentDetail: vi.fn(),
|
||||
onUpdateSegment: vi.fn(),
|
||||
isRegenerationModalOpen: false,
|
||||
setIsRegenerationModalOpen: vi.fn(),
|
||||
showNewSegmentModal: false,
|
||||
onCloseNewSegmentModal: vi.fn(),
|
||||
onSaveNewSegment: vi.fn(),
|
||||
@ -55,7 +53,7 @@ describe('DrawerGroup', () => {
|
||||
|
||||
it('should render nothing when all modals are closed', () => {
|
||||
const { container } = render(<DrawerGroup {...defaultProps} />)
|
||||
expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull()
|
||||
expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render segment detail when segment modal is open', () => {
|
||||
@ -66,6 +64,7 @@ describe('DrawerGroup', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('segment-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
|
||||
})
|
||||
|
||||
it('should render new segment modal when showNewSegmentModal is true', () => {
|
||||
@ -73,6 +72,7 @@ describe('DrawerGroup', () => {
|
||||
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
|
||||
)
|
||||
expect(screen.getByTestId('new-segment')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
|
||||
})
|
||||
|
||||
it('should render child segment detail when child chunk modal is open', () => {
|
||||
@ -83,6 +83,7 @@ describe('DrawerGroup', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
|
||||
})
|
||||
|
||||
it('should render new child segment modal when showNewChildSegmentModal is true', () => {
|
||||
@ -90,6 +91,7 @@ describe('DrawerGroup', () => {
|
||||
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
|
||||
)
|
||||
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
|
||||
})
|
||||
|
||||
it('should render multiple drawers simultaneously', () => {
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
|
||||
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
|
||||
import ChildSegmentDetail from '../child-segment-detail'
|
||||
import FullScreenDrawer from '../common/full-screen-drawer'
|
||||
import { DocumentDetailDrawer } from '../common/full-screen-drawer'
|
||||
import NewChildSegment from '../new-child-segment'
|
||||
import SegmentDetail from '../segment-detail'
|
||||
import { SegmentDetail } from '../segment-detail'
|
||||
|
||||
type DrawerGroupProps = {
|
||||
// Segment detail drawer
|
||||
currSegment: {
|
||||
segInfo?: SegmentDetailModel
|
||||
showModal: boolean
|
||||
@ -25,14 +23,10 @@ type DrawerGroupProps = {
|
||||
summary?: string,
|
||||
needRegenerate?: boolean,
|
||||
) => Promise<void>
|
||||
isRegenerationModalOpen: boolean
|
||||
setIsRegenerationModalOpen: (open: boolean) => void
|
||||
// New segment drawer
|
||||
showNewSegmentModal: boolean
|
||||
onCloseNewSegmentModal: () => void
|
||||
onSaveNewSegment: () => void
|
||||
viewNewlyAddedChunk: () => void
|
||||
// Child segment detail drawer
|
||||
currChildChunk: {
|
||||
childChunkInfo?: ChildChunkDetail
|
||||
showModal: boolean
|
||||
@ -40,52 +34,39 @@ type DrawerGroupProps = {
|
||||
currChunkId: string
|
||||
onCloseChildSegmentDetail: () => void
|
||||
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
|
||||
// New child segment drawer
|
||||
showNewChildSegmentModal: boolean
|
||||
onCloseNewChildChunkModal: () => void
|
||||
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
|
||||
viewNewlyAddedChildChunk: () => void
|
||||
// Common props
|
||||
fullScreen: boolean
|
||||
docForm: ChunkingMode
|
||||
}
|
||||
|
||||
const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
// Segment detail drawer
|
||||
export function DrawerGroup({
|
||||
currSegment,
|
||||
onCloseSegmentDetail,
|
||||
onUpdateSegment,
|
||||
isRegenerationModalOpen,
|
||||
setIsRegenerationModalOpen,
|
||||
// New segment drawer
|
||||
showNewSegmentModal,
|
||||
onCloseNewSegmentModal,
|
||||
onSaveNewSegment,
|
||||
viewNewlyAddedChunk,
|
||||
// Child segment detail drawer
|
||||
currChildChunk,
|
||||
currChunkId,
|
||||
onCloseChildSegmentDetail,
|
||||
onUpdateChildChunk,
|
||||
// New child segment drawer
|
||||
showNewChildSegmentModal,
|
||||
onCloseNewChildChunkModal,
|
||||
onSaveNewChildChunk,
|
||||
viewNewlyAddedChildChunk,
|
||||
// Common props
|
||||
fullScreen,
|
||||
docForm,
|
||||
}) => {
|
||||
}: DrawerGroupProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Edit or view segment detail */}
|
||||
<FullScreenDrawer
|
||||
isOpen={currSegment.showModal}
|
||||
<DocumentDetailDrawer
|
||||
open={currSegment.showModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseSegmentDetail}
|
||||
showOverlay={false}
|
||||
needCheckChunks
|
||||
modal={isRegenerationModalOpen}
|
||||
>
|
||||
<SegmentDetail
|
||||
key={currSegment.segInfo?.id}
|
||||
@ -94,13 +75,11 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
isEditMode={currSegment.isEditMode}
|
||||
onUpdate={onUpdateSegment}
|
||||
onCancel={onCloseSegmentDetail}
|
||||
onModalStateChange={setIsRegenerationModalOpen}
|
||||
/>
|
||||
</FullScreenDrawer>
|
||||
</DocumentDetailDrawer>
|
||||
|
||||
{/* Create New Segment */}
|
||||
<FullScreenDrawer
|
||||
isOpen={showNewSegmentModal}
|
||||
<DocumentDetailDrawer
|
||||
open={showNewSegmentModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseNewSegmentModal}
|
||||
modal
|
||||
@ -111,15 +90,12 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
onSave={onSaveNewSegment}
|
||||
viewNewlyAddedChunk={viewNewlyAddedChunk}
|
||||
/>
|
||||
</FullScreenDrawer>
|
||||
</DocumentDetailDrawer>
|
||||
|
||||
{/* Edit or view child segment detail */}
|
||||
<FullScreenDrawer
|
||||
isOpen={currChildChunk.showModal}
|
||||
<DocumentDetailDrawer
|
||||
open={currChildChunk.showModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseChildSegmentDetail}
|
||||
showOverlay={false}
|
||||
needCheckChunks
|
||||
>
|
||||
<ChildSegmentDetail
|
||||
key={currChildChunk.childChunkInfo?.id}
|
||||
@ -129,11 +105,10 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
onUpdate={onUpdateChildChunk}
|
||||
onCancel={onCloseChildSegmentDetail}
|
||||
/>
|
||||
</FullScreenDrawer>
|
||||
</DocumentDetailDrawer>
|
||||
|
||||
{/* Create New Child Segment */}
|
||||
<FullScreenDrawer
|
||||
isOpen={showNewChildSegmentModal}
|
||||
<DocumentDetailDrawer
|
||||
open={showNewChildSegmentModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseNewChildChunkModal}
|
||||
modal
|
||||
@ -144,9 +119,7 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
onSave={onSaveNewChildChunk}
|
||||
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
|
||||
/>
|
||||
</FullScreenDrawer>
|
||||
</DocumentDetailDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DrawerGroup
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export { default as DrawerGroup } from './drawer-group'
|
||||
export { default as MenuBar } from './menu-bar'
|
||||
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'
|
||||
@ -1,7 +1,9 @@
|
||||
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useModalState } from '../use-modal-state'
|
||||
import * as modalStateHooks from '../use-modal-state'
|
||||
|
||||
const renderDatasetModalState = modalStateHooks.useModalState
|
||||
|
||||
describe('useModalState', () => {
|
||||
const onNewSegmentModalChange = vi.fn()
|
||||
@ -10,22 +12,21 @@ describe('useModalState', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderUseModalState = () =>
|
||||
renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
const renderModalState = () =>
|
||||
renderHook(() => renderDatasetModalState({ onNewSegmentModalChange }))
|
||||
|
||||
it('should initialize with all modals closed', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
expect(result.current.currSegment.showModal).toBe(false)
|
||||
expect(result.current.currChildChunk.showModal).toBe(false)
|
||||
expect(result.current.showNewChildSegmentModal).toBe(false)
|
||||
expect(result.current.isRegenerationModalOpen).toBe(false)
|
||||
expect(result.current.fullScreen).toBe(false)
|
||||
expect(result.current.isCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('should open segment detail on card click', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel
|
||||
|
||||
act(() => {
|
||||
@ -37,8 +38,25 @@ describe('useModalState', () => {
|
||||
expect(result.current.currSegment.isEditMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should close child detail when opening segment detail', () => {
|
||||
const { result } = renderModalState()
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
|
||||
|
||||
act(() => {
|
||||
result.current.onClickSlice(childDetail)
|
||||
})
|
||||
act(() => {
|
||||
result.current.onClickCard(segmentDetail)
|
||||
})
|
||||
|
||||
expect(result.current.currSegment.showModal).toBe(true)
|
||||
expect(result.current.currSegment.segInfo).toBe(segmentDetail)
|
||||
expect(result.current.currChildChunk.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close segment detail and reset fullscreen', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel)
|
||||
@ -55,7 +73,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should open child segment detail on slice click', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
|
||||
act(() => {
|
||||
@ -67,8 +85,25 @@ describe('useModalState', () => {
|
||||
expect(result.current.currChunkId).toBe('seg-1')
|
||||
})
|
||||
|
||||
it('should close segment detail when opening child detail', () => {
|
||||
const { result } = renderModalState()
|
||||
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
|
||||
act(() => {
|
||||
result.current.onClickCard(segmentDetail)
|
||||
})
|
||||
act(() => {
|
||||
result.current.onClickSlice(childDetail)
|
||||
})
|
||||
|
||||
expect(result.current.currSegment.showModal).toBe(false)
|
||||
expect(result.current.currChildChunk.showModal).toBe(true)
|
||||
expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail)
|
||||
})
|
||||
|
||||
it('should close child segment detail', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail)
|
||||
@ -81,7 +116,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should handle new child chunk modal', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddNewChildChunk('parent-chunk-1')
|
||||
@ -98,7 +133,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should close new segment modal and notify parent', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseNewSegmentModal()
|
||||
@ -108,7 +143,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should toggle full screen', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
@ -122,7 +157,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should toggle collapsed', () => {
|
||||
const { result } = renderUseModalState()
|
||||
const { result } = renderModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleCollapsed()
|
||||
@ -134,13 +169,4 @@ describe('useModalState', () => {
|
||||
})
|
||||
expect(result.current.isCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('should set regeneration modal state', () => {
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsRegenerationModalOpen(true)
|
||||
})
|
||||
expect(result.current.isRegenerationModalOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
|
||||
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
|
||||
import { ProcessStatus } from '../../../segment-add'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import { useSegmentListData } from '../use-segment-list-data'
|
||||
|
||||
// Type for mutation callbacks
|
||||
@ -176,7 +177,7 @@ const defaultOptions = {
|
||||
searchValue: '',
|
||||
selectedStatus: 'all' as boolean | 'all',
|
||||
selectedSegmentIds: [] as string[],
|
||||
importStatus: undefined as ProcessStatus | string | undefined,
|
||||
importStatus: undefined as SegmentImportStatus | undefined,
|
||||
currentPage: 1,
|
||||
limit: 10,
|
||||
onCloseSegmentDetail: vi.fn(),
|
||||
@ -689,7 +690,7 @@ describe('useSegmentListData', () => {
|
||||
|
||||
renderHook(() => useSegmentListData({
|
||||
...defaultOptions,
|
||||
importStatus: ProcessStatus.COMPLETED,
|
||||
importStatus: segmentImportStatus.completed,
|
||||
clearSelection,
|
||||
}), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@ -13,29 +13,20 @@ type CurrChildChunkType = {
|
||||
}
|
||||
|
||||
type UseModalStateReturn = {
|
||||
// Segment detail modal
|
||||
currSegment: CurrSegmentType
|
||||
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
|
||||
onCloseSegmentDetail: () => void
|
||||
// Child segment detail modal
|
||||
currChildChunk: CurrChildChunkType
|
||||
currChunkId: string
|
||||
onClickSlice: (detail: ChildChunkDetail) => void
|
||||
onCloseChildSegmentDetail: () => void
|
||||
// New segment modal
|
||||
onCloseNewSegmentModal: () => void
|
||||
// New child segment modal
|
||||
showNewChildSegmentModal: boolean
|
||||
handleAddNewChildChunk: (parentChunkId: string) => void
|
||||
onCloseNewChildChunkModal: () => void
|
||||
// Regeneration modal
|
||||
isRegenerationModalOpen: boolean
|
||||
setIsRegenerationModalOpen: (open: boolean) => void
|
||||
// Full screen
|
||||
fullScreen: boolean
|
||||
toggleFullScreen: () => void
|
||||
setFullScreen: (fullScreen: boolean) => void
|
||||
// Collapsed state
|
||||
isCollapsed: boolean
|
||||
toggleCollapsed: () => void
|
||||
}
|
||||
@ -47,25 +38,15 @@ type UseModalStateOptions = {
|
||||
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
|
||||
const { onNewSegmentModalChange } = options
|
||||
|
||||
// Segment detail modal state
|
||||
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
|
||||
|
||||
// Child segment detail modal state
|
||||
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
|
||||
const [currChunkId, setCurrChunkId] = useState('')
|
||||
|
||||
// New child segment modal state
|
||||
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
|
||||
|
||||
// Regeneration modal state
|
||||
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
|
||||
|
||||
// Display state
|
||||
const [fullScreen, setFullScreen] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
|
||||
// Segment detail handlers
|
||||
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
|
||||
setCurrChildChunk({ showModal: false })
|
||||
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
|
||||
}, [])
|
||||
|
||||
@ -74,8 +55,8 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// Child segment detail handlers
|
||||
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
|
||||
setCurrSegment({ showModal: false })
|
||||
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
|
||||
setCurrChunkId(detail.segment_id)
|
||||
}, [])
|
||||
@ -85,13 +66,11 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// New segment modal handlers
|
||||
const onCloseNewSegmentModal = useCallback(() => {
|
||||
onNewSegmentModalChange(false)
|
||||
setFullScreen(false)
|
||||
}, [onNewSegmentModalChange])
|
||||
|
||||
// New child segment modal handlers
|
||||
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
|
||||
setShowNewChildSegmentModal(true)
|
||||
setCurrChunkId(parentChunkId)
|
||||
@ -102,7 +81,6 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// Display handlers - handles both direct calls and click events
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
setFullScreen(prev => !prev)
|
||||
}, [])
|
||||
@ -112,29 +90,20 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Segment detail modal
|
||||
currSegment,
|
||||
onClickCard,
|
||||
onCloseSegmentDetail,
|
||||
// Child segment detail modal
|
||||
currChildChunk,
|
||||
currChunkId,
|
||||
onClickSlice,
|
||||
onCloseChildSegmentDetail,
|
||||
// New segment modal
|
||||
onCloseNewSegmentModal,
|
||||
// New child segment modal
|
||||
showNewChildSegmentModal,
|
||||
handleAddNewChildChunk,
|
||||
onCloseNewChildChunkModal,
|
||||
// Regeneration modal
|
||||
isRegenerationModalOpen,
|
||||
setIsRegenerationModalOpen,
|
||||
// Full screen
|
||||
fullScreen,
|
||||
toggleFullScreen,
|
||||
setFullScreen,
|
||||
// Collapsed state
|
||||
isCollapsed,
|
||||
toggleCollapsed,
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@ -9,16 +10,16 @@ import { ChunkingMode } from '@/models/datasets'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { useDocumentContext } from '../../context'
|
||||
import { ProcessStatus } from '../../segment-add'
|
||||
|
||||
const DEFAULT_LIMIT = 10
|
||||
type UseSegmentListDataOptions = {
|
||||
searchValue: string
|
||||
selectedStatus: boolean | 'all'
|
||||
selectedSegmentIds: string[]
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
currentPage: number
|
||||
limit: number
|
||||
onCloseSegmentDetail: () => void
|
||||
@ -92,7 +93,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
|
||||
}, [pathname])
|
||||
// Reset list on import completion
|
||||
useEffect(() => {
|
||||
if (importStatus === ProcessStatus.COMPLETED) {
|
||||
if (importStatus === segmentImportStatus.completed) {
|
||||
clearSelection()
|
||||
invalidSegmentList()
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessStatus } from '../segment-add'
|
||||
import type { SegmentListContextValue } from './segment-list-context'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
@ -13,7 +13,9 @@ import {
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useDocumentContext } from '../context'
|
||||
import BatchAction from './common/batch-action'
|
||||
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
|
||||
import { DrawerGroup } from './components/drawer-group'
|
||||
import MenuBar from './components/menu-bar'
|
||||
import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content'
|
||||
import {
|
||||
useChildSegmentData,
|
||||
useModalState,
|
||||
@ -32,7 +34,7 @@ type ICompletedProps = {
|
||||
embeddingAvailable: boolean
|
||||
showNewSegmentModal: boolean
|
||||
onNewSegmentModalChange: (state: boolean) => void
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
@ -225,8 +227,6 @@ const Completed: FC<ICompletedProps> = ({
|
||||
currSegment={modalState.currSegment}
|
||||
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
|
||||
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
|
||||
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
|
||||
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
|
||||
showNewSegmentModal={showNewSegmentModal}
|
||||
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
|
||||
onSaveNewSegment={segmentListDataHook.resetList}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { SegmentDetailModel } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@ -7,7 +6,6 @@ import {
|
||||
RiCollapseDiagonalLine,
|
||||
RiExpandDiagonalLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
@ -42,20 +40,15 @@ type ISegmentDetailProps = {
|
||||
onCancel: () => void
|
||||
isEditMode?: boolean
|
||||
docForm: ChunkingMode
|
||||
onModalStateChange?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all the contents of the segment
|
||||
*/
|
||||
const SegmentDetail: FC<ISegmentDetailProps> = ({
|
||||
export function SegmentDetail({
|
||||
segInfo,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
isEditMode,
|
||||
docForm,
|
||||
onModalStateChange,
|
||||
}) => {
|
||||
}: ISegmentDetailProps) {
|
||||
const { t } = useTranslation()
|
||||
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
|
||||
const [answer, setAnswer] = useState(segInfo?.answer || '')
|
||||
@ -99,19 +92,16 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
|
||||
|
||||
const handleRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(true)
|
||||
onModalStateChange?.(true)
|
||||
}, [onModalStateChange])
|
||||
}, [])
|
||||
|
||||
const onCancelRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(false)
|
||||
onModalStateChange?.(false)
|
||||
}, [onModalStateChange])
|
||||
}, [])
|
||||
|
||||
const onCloseAfterRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(false)
|
||||
onModalStateChange?.(false)
|
||||
onCancel() // Close the edit drawer
|
||||
}, [onCancel, onModalStateChange])
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
|
||||
const onConfirmRegeneration = useCallback(() => {
|
||||
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
|
||||
@ -241,5 +231,3 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SegmentDetail)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
@ -17,6 +18,7 @@ import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import Operations from '../components/operations'
|
||||
import StatusItem from '../status-item'
|
||||
import BatchModal from './batch-modal'
|
||||
@ -24,7 +26,7 @@ import Completed from './completed'
|
||||
import { DocumentContext } from './context'
|
||||
import { DocumentTitle } from './document-title'
|
||||
import Embedding from './embedding'
|
||||
import SegmentAdd, { ProcessStatus } from './segment-add'
|
||||
import { SegmentAdd } from './segment-add'
|
||||
import style from './style.module.css'
|
||||
|
||||
type DocumentDetailProps = {
|
||||
@ -53,20 +55,20 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
const [showMetadata, setShowMetadata] = useState(!isMobile)
|
||||
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
|
||||
const [batchModalVisible, setBatchModalVisible] = useState(false)
|
||||
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
|
||||
const [importStatus, setImportStatus] = useState<SegmentImportStatus>()
|
||||
const showNewSegmentModal = () => setNewSegmentModalVisible(true)
|
||||
const showBatchModal = () => setBatchModalVisible(true)
|
||||
const hideBatchModal = () => setBatchModalVisible(false)
|
||||
const resetProcessStatus = () => setImportStatus('')
|
||||
const resetImportStatus = () => setImportStatus(undefined)
|
||||
|
||||
const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress()
|
||||
const checkProcess = async (jobID: string) => {
|
||||
await checkSegmentBatchImportProgress({ jobID }, {
|
||||
onSuccess: (res) => {
|
||||
setImportStatus(res.job_status)
|
||||
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
|
||||
if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing)
|
||||
setTimeout(() => checkProcess(res.job_id), 2500)
|
||||
if (res.job_status === ProcessStatus.ERROR)
|
||||
if (res.job_status === segmentImportStatus.error)
|
||||
toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`)
|
||||
},
|
||||
onError: (e) => {
|
||||
@ -222,7 +224,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
<>
|
||||
<SegmentAdd
|
||||
importStatus={importStatus}
|
||||
clearProcessStatus={resetProcessStatus}
|
||||
clearImportStatus={resetImportStatus}
|
||||
showNewSegmentModal={showNewSegmentModal}
|
||||
showBatchModal={showBatchModal}
|
||||
embedding={embedding}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
|
||||
import SegmentAdd, { ProcessStatus } from '../index'
|
||||
import { SegmentAdd } from '../index'
|
||||
|
||||
// Mock provider context
|
||||
let mockPlan = { type: Plan.professional }
|
||||
@ -22,8 +24,8 @@ describe('SegmentAdd', () => {
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
importStatus: undefined as ProcessStatus | string | undefined,
|
||||
clearProcessStatus: vi.fn(),
|
||||
importStatus: undefined as SegmentImportStatus | undefined,
|
||||
clearImportStatus: vi.fn(),
|
||||
showNewSegmentModal: vi.fn(),
|
||||
showBatchModal: vi.fn(),
|
||||
embedding: false,
|
||||
@ -52,33 +54,33 @@ describe('SegmentAdd', () => {
|
||||
// Import Status displays
|
||||
describe('Import Status Display', () => {
|
||||
it('should show processing indicator when status is WAITING', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show processing indicator when status is PROCESSING', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show completed status with ok button', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.completed} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error status with ok button', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.error} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show add button when importStatus is set', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
|
||||
expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
|
||||
})
|
||||
@ -94,34 +96,34 @@ describe('SegmentAdd', () => {
|
||||
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearProcessStatus when ok is clicked on completed status', () => {
|
||||
const mockClearProcessStatus = vi.fn()
|
||||
it('should call clearImportStatus when ok is clicked on completed status', () => {
|
||||
const mockClearImportStatus = vi.fn()
|
||||
render(
|
||||
<SegmentAdd
|
||||
{...defaultProps}
|
||||
importStatus={ProcessStatus.COMPLETED}
|
||||
clearProcessStatus={mockClearProcessStatus}
|
||||
importStatus={segmentImportStatus.completed}
|
||||
clearImportStatus={mockClearImportStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
|
||||
|
||||
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearProcessStatus when ok is clicked on error status', () => {
|
||||
const mockClearProcessStatus = vi.fn()
|
||||
it('should call clearImportStatus when ok is clicked on error status', () => {
|
||||
const mockClearImportStatus = vi.fn()
|
||||
render(
|
||||
<SegmentAdd
|
||||
{...defaultProps}
|
||||
importStatus={ProcessStatus.ERROR}
|
||||
clearProcessStatus={mockClearProcessStatus}
|
||||
importStatus={segmentImportStatus.error}
|
||||
clearImportStatus={mockClearImportStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
|
||||
|
||||
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render batch add option in dropdown', async () => {
|
||||
@ -215,14 +217,14 @@ describe('SegmentAdd', () => {
|
||||
// Progress bar width tests
|
||||
describe('Progress Bar', () => {
|
||||
it('should show 3/12 width progress bar for WAITING status', () => {
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
|
||||
|
||||
const progressBar = container.querySelector('.w-3\\/12')
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 2/3 width progress bar for PROCESSING status', () => {
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
|
||||
const progressBar = container.querySelector('.w-2\\/3')
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
@ -230,15 +232,6 @@ describe('SegmentAdd', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle unknown importStatus string', () => {
|
||||
// Arrange & Act - pass unknown status
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus="unknown" />)
|
||||
|
||||
// Assert - empty fragment is rendered for unknown status (container exists but has no visible content)
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
const { rerender } = render(<SegmentAdd {...defaultProps} />)
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -7,95 +7,92 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
|
||||
type ISegmentAddProps = {
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
clearProcessStatus: () => void
|
||||
type SegmentAddProps = {
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
clearImportStatus: () => void
|
||||
showNewSegmentModal: () => void
|
||||
showBatchModal: () => void
|
||||
embedding: boolean
|
||||
}
|
||||
|
||||
export enum ProcessStatus {
|
||||
WAITING = 'waiting',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
const SegmentAdd: FC<ISegmentAddProps> = ({
|
||||
export function SegmentAdd({
|
||||
importStatus,
|
||||
clearProcessStatus,
|
||||
clearImportStatus,
|
||||
showNewSegmentModal,
|
||||
showBatchModal,
|
||||
embedding,
|
||||
}) => {
|
||||
}: SegmentAddProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isShowPlanUpgradeModal, {
|
||||
setTrue: showPlanUpgradeModal,
|
||||
setFalse: hidePlanUpgradeModal,
|
||||
}] = useBoolean(false)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const { type } = plan
|
||||
const canAdd = enableBilling ? type !== Plan.sandbox : true
|
||||
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
|
||||
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
|
||||
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
|
||||
|
||||
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
|
||||
return () => {
|
||||
if (!canAdd) {
|
||||
showPlanUpgradeModal()
|
||||
return
|
||||
}
|
||||
fn()
|
||||
const textColor = embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (!canAddChunks) {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
}
|
||||
}, [canAdd, showPlanUpgradeModal])
|
||||
const textColor = useMemo(() => {
|
||||
return embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
}, [embedding])
|
||||
|
||||
showNewSegmentModal()
|
||||
}
|
||||
|
||||
const handleBatchAddClick = () => {
|
||||
setIsBatchMenuOpen(false)
|
||||
|
||||
if (!canAddChunks) {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
showBatchModal()
|
||||
}
|
||||
|
||||
if (importStatus) {
|
||||
return (
|
||||
<>
|
||||
{(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && (
|
||||
{(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
|
||||
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border
|
||||
bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text
|
||||
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
|
||||
>
|
||||
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
|
||||
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3')} />
|
||||
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
|
||||
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importStatus === ProcessStatus.COMPLETED && (
|
||||
{importStatus === segmentImportStatus.completed && (
|
||||
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
|
||||
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
|
||||
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
|
||||
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="m-1 inline-flex items-center">
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" />
|
||||
</div>
|
||||
)}
|
||||
{importStatus === ProcessStatus.ERROR && (
|
||||
{importStatus === segmentImportStatus.error && (
|
||||
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
|
||||
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
|
||||
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
|
||||
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="m-1 inline-flex items-center">
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" />
|
||||
</div>
|
||||
@ -116,7 +113,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2
|
||||
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
|
||||
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
|
||||
onClick={handleAddClick}
|
||||
disabled={embedding}
|
||||
>
|
||||
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
|
||||
@ -142,25 +139,20 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
positionerProps={{ anchor: batchMenuAnchorRef }}
|
||||
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
popupClassName="w-[var(--anchor-width)]"
|
||||
>
|
||||
<div className="w-full p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto w-full px-2 py-1.5 system-md-regular"
|
||||
onClick={() => {
|
||||
setIsBatchMenuOpen(false)
|
||||
withNeedUpgradeCheck(showBatchModal)()
|
||||
}}
|
||||
>
|
||||
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
className="system-md-regular"
|
||||
onClick={handleBatchAddClick}
|
||||
>
|
||||
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isShowPlanUpgradeModal && (
|
||||
{isPlanUpgradeModalOpen && (
|
||||
<PlanUpgradeModal
|
||||
show
|
||||
onClose={hidePlanUpgradeModal}
|
||||
onClose={() => setIsPlanUpgradeModalOpen(false)}
|
||||
title={t('upgrade.addChunks.title', { ns: 'billing' })!}
|
||||
description={t('upgrade.addChunks.description', { ns: 'billing' })!}
|
||||
/>
|
||||
@ -169,4 +161,3 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(SegmentAdd)
|
||||
|
||||
@ -22,7 +22,7 @@ const createApp = (overrides?: Partial<App>): App => ({
|
||||
copyright: '2024',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
categories: ['Assistant'],
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 0,
|
||||
@ -167,7 +167,7 @@ describe('AppCard', () => {
|
||||
template_id: app.app_id,
|
||||
template_name: app.app.name,
|
||||
template_mode: app.app.mode,
|
||||
template_category: app.category,
|
||||
template_categories: app.categories,
|
||||
page: 'explore',
|
||||
})
|
||||
})
|
||||
|
||||
@ -37,7 +37,7 @@ const AppCard = ({
|
||||
template_id: app.app_id,
|
||||
template_name: appBasicInfo.name,
|
||||
template_mode: appBasicInfo.mode,
|
||||
template_category: app.category,
|
||||
template_categories: app.categories,
|
||||
page: 'explore',
|
||||
})
|
||||
onTry({ appId: app.app_id, app })
|
||||
|
||||
@ -115,7 +115,7 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
copyright: overrides.copyright ?? '',
|
||||
privacy_policy: overrides.privacy_policy ?? null,
|
||||
custom_disclaimer: overrides.custom_disclaimer ?? null,
|
||||
category: overrides.category ?? 'Writing',
|
||||
categories: overrides.categories ?? ['Writing'],
|
||||
position: overrides.position ?? 1,
|
||||
is_listed: overrides.is_listed ?? true,
|
||||
install_count: overrides.install_count ?? 0,
|
||||
@ -185,7 +185,7 @@ describe('AppList', () => {
|
||||
it('should render app cards when data is available', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })],
|
||||
}
|
||||
|
||||
renderAppList()
|
||||
@ -199,7 +199,7 @@ describe('AppList', () => {
|
||||
it('should filter apps by selected category', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })],
|
||||
}
|
||||
|
||||
renderAppList(false, undefined, { category: 'Writing' })
|
||||
|
||||
@ -77,7 +77,10 @@ const Apps = ({
|
||||
const filteredList = useMemo(() => {
|
||||
if (!data)
|
||||
return []
|
||||
return data.allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory)
|
||||
return data.allList.filter(item => (
|
||||
currCategory === allCategoriesEn
|
||||
|| item.categories?.includes(currCategory)
|
||||
))
|
||||
}, [data, currCategory, allCategoriesEn])
|
||||
|
||||
const searchFilteredList = useMemo(() => {
|
||||
@ -277,7 +280,7 @@ const Apps = ({
|
||||
<TryApp
|
||||
appId={currentTryApp?.appId || ''}
|
||||
app={currentTryApp?.app}
|
||||
category={currentTryApp?.app?.category}
|
||||
categories={currentTryApp?.app?.categories}
|
||||
onClose={hideTryAppPanel}
|
||||
onCreate={handleShowFromTryApp}
|
||||
/>
|
||||
|
||||
@ -33,6 +33,11 @@ const Category: FC<ICategoryProps> = ({
|
||||
isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)
|
||||
|
||||
const renderCategoryName = (name: AppCategory) => {
|
||||
const categoryKey = `category.${name}` as keyof typeof exploreI18n
|
||||
return categoryKey in exploreI18n ? t(categoryKey, { ns: 'explore' }) : name
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex flex-wrap gap-1 text-[13px]')}>
|
||||
<div
|
||||
@ -48,7 +53,7 @@ const Category: FC<ICategoryProps> = ({
|
||||
className={itemClassName(name === value)}
|
||||
onClick={() => onChange(name)}
|
||||
>
|
||||
{`category.${name}` in exploreI18n ? t(`category.${name}`, { ns: 'explore' }) : name}
|
||||
{renderCategoryName(name)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -39,14 +39,14 @@ vi.mock('../app-info', () => ({
|
||||
default: ({
|
||||
appId,
|
||||
appDetail,
|
||||
category,
|
||||
categories,
|
||||
className,
|
||||
onCreate,
|
||||
}: { appId: string, appDetail: TryAppInfo, category?: string, className?: string, onCreate: () => void }) => (
|
||||
}: { appId: string, appDetail: TryAppInfo, categories?: string[], className?: string, onCreate: () => void }) => (
|
||||
<div
|
||||
data-testid="app-info-component"
|
||||
data-app-id={appId}
|
||||
data-category={category}
|
||||
data-categories={categories?.join(',')}
|
||||
className={className}
|
||||
>
|
||||
<button data-testid="create-button" onClick={onCreate}>Create</button>
|
||||
@ -283,12 +283,12 @@ describe('TryApp (main index.tsx)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('category prop', () => {
|
||||
it('passes category to AppInfo when provided', async () => {
|
||||
describe('categories prop', () => {
|
||||
it('passes categories to AppInfo when provided', async () => {
|
||||
render(
|
||||
<TryApp
|
||||
appId="test-app-id"
|
||||
category="AI Assistant"
|
||||
categories={['AI Assistant', 'Workflow']}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
/>,
|
||||
@ -296,11 +296,11 @@ describe('TryApp (main index.tsx)', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const appInfo = document.body.querySelector('[data-testid="app-info-component"]')
|
||||
expect(appInfo).toHaveAttribute('data-category', 'AI Assistant')
|
||||
expect(appInfo).toHaveAttribute('data-categories', 'AI Assistant,Workflow')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not pass category to AppInfo when not provided', async () => {
|
||||
it('does not pass categories to AppInfo when not provided', async () => {
|
||||
render(
|
||||
<TryApp
|
||||
appId="test-app-id"
|
||||
@ -311,7 +311,7 @@ describe('TryApp (main index.tsx)', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const appInfo = document.body.querySelector('[data-testid="app-info-component"]')
|
||||
expect(appInfo).not.toHaveAttribute('data-category', expect.any(String))
|
||||
expect(appInfo).not.toHaveAttribute('data-categories', expect.any(String))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -235,8 +235,8 @@ describe('AppInfo', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('category', () => {
|
||||
it('renders category when provided', () => {
|
||||
describe('categories', () => {
|
||||
it('renders categories when provided', () => {
|
||||
const appDetail = createMockAppDetail('chat')
|
||||
const mockOnCreate = vi.fn()
|
||||
|
||||
@ -244,16 +244,17 @@ describe('AppInfo', () => {
|
||||
<AppInfo
|
||||
appId="test-app-id"
|
||||
appDetail={appDetail}
|
||||
category="AI Assistant"
|
||||
categories={['AI Assistant', 'Workflow']}
|
||||
onCreate={mockOnCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI Assistant')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render category section when not provided', () => {
|
||||
it('does not render categories section when not provided', () => {
|
||||
const appDetail = createMockAppDetail('chat')
|
||||
const mockOnCreate = vi.fn()
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import useGetRequirements from './use-get-requirements'
|
||||
type Props = {
|
||||
appId: string
|
||||
appDetail: TryAppInfo
|
||||
category?: string
|
||||
categories?: string[]
|
||||
className?: string
|
||||
onCreate: () => void
|
||||
}
|
||||
@ -52,12 +52,13 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
|
||||
const AppInfo: FC<Props> = ({
|
||||
appId,
|
||||
className,
|
||||
category,
|
||||
categories,
|
||||
appDetail,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const mode = appDetail?.mode
|
||||
const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? []))
|
||||
const { requirements } = useGetRequirements({ appDetail, appId })
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col px-4 pt-2', className)}>
|
||||
@ -98,10 +99,19 @@ const AppInfo: FC<Props> = ({
|
||||
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
|
||||
{category && (
|
||||
{visibleCategories.length > 0 && (
|
||||
<div className="mt-6 shrink-0">
|
||||
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
|
||||
<div className="system-md-regular text-text-secondary">{category}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleCategories.map(category => (
|
||||
<span
|
||||
key={category}
|
||||
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-2 py-0.5 system-xs-medium text-text-secondary shadow-xs"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
|
||||
@ -20,7 +20,7 @@ import Tab, { TypeEnum } from './tab'
|
||||
type Props = {
|
||||
appId: string
|
||||
app?: AppType
|
||||
category?: string
|
||||
categories?: string[]
|
||||
onClose: () => void
|
||||
onCreate: () => void
|
||||
}
|
||||
@ -28,7 +28,7 @@ type Props = {
|
||||
const TryApp: FC<Props> = ({
|
||||
appId,
|
||||
app,
|
||||
category,
|
||||
categories,
|
||||
onClose,
|
||||
onCreate,
|
||||
}) => {
|
||||
@ -85,7 +85,7 @@ const TryApp: FC<Props> = ({
|
||||
className="w-[360px] shrink-0"
|
||||
appDetail={appDetail}
|
||||
appId={appId}
|
||||
category={category}
|
||||
categories={categories}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -55,10 +55,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/encrypted-bottom', () => ({
|
||||
EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
|
||||
}))
|
||||
|
||||
@ -41,10 +41,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
|
||||
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
|
||||
mockAuthFormProps = props
|
||||
|
||||
@ -19,7 +19,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
useAddPluginCredentialHook,
|
||||
useGetPluginCredentialSchemaHook,
|
||||
@ -159,7 +158,7 @@ const ApiKeyModal = ({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
|
||||
)}
|
||||
{
|
||||
isLoading && (
|
||||
|
||||
@ -19,7 +19,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
useDeletePluginOAuthCustomClientHook,
|
||||
useInvalidPluginOAuthClientSchemaHook,
|
||||
@ -157,7 +156,7 @@ const OAuthClientSettings = ({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
|
||||
@ -14,7 +14,6 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
|
||||
import { parsePluginErrorMessage } from '@/utils/error-parser'
|
||||
import { ReadmeShowType } from '../../../readme-panel/store'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
|
||||
@ -318,7 +317,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
|
||||
</div>
|
||||
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginDetail && (
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
|
||||
)}
|
||||
|
||||
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />
|
||||
|
||||
@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
|
||||
import { ReadmeShowType } from '../../../readme-panel/store'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
|
||||
@ -159,7 +158,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
|
||||
</div>
|
||||
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginDetail && (
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
|
||||
)}
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
|
||||
@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
|
||||
import { ReadmeShowType } from '../../../readme-panel/store'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
|
||||
@ -173,7 +172,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
|
||||
</div>
|
||||
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginDetail && (
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
|
||||
)}
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
|
||||
@ -1,31 +1,11 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockSetCurrentPluginDetail = vi.fn()
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' },
|
||||
useReadmePanelStore: () => ({
|
||||
setCurrentPluginDetail: mockSetCurrentPluginDetail,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../constants', () => ({
|
||||
BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'],
|
||||
}))
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ReadmeEntrance } from '../entrance'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
|
||||
describe('ReadmeEntrance', () => {
|
||||
let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../entrance')
|
||||
ReadmeEntrance = mod.ReadmeEntrance
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
})
|
||||
|
||||
it('should render readme button for non-builtin plugin with unique identifier', () => {
|
||||
@ -35,18 +15,31 @@ describe('ReadmeEntrance', () => {
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentPluginDetail on button click', () => {
|
||||
it('should open drawer presentation by default', () => {
|
||||
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
|
||||
render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer')
|
||||
expect(useReadmePanelStore.getState().currentPanel).toEqual({
|
||||
detail: pluginDetail,
|
||||
presentation: 'drawer',
|
||||
triggerId: button.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open dialog presentation when requested', () => {
|
||||
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
|
||||
render(<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
})
|
||||
|
||||
it('should return null for builtin tools', () => {
|
||||
const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never
|
||||
const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { PluginDetail } from '../../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
import { ReadmeEntrance } from '../entrance'
|
||||
import ReadmePanel from '../index'
|
||||
import { ReadmeShowType, useReadmePanelStore } from '../store'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
).BASE_UI_ANIMATIONS_DISABLED = true
|
||||
|
||||
// Mock usePluginReadme hook
|
||||
const mockUsePluginReadme = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params),
|
||||
}))
|
||||
|
||||
// Mock useLanguage hook
|
||||
let mockLanguage = 'en-US'
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => mockLanguage,
|
||||
}))
|
||||
|
||||
// Mock DetailHeader component (complex component with many dependencies)
|
||||
vi.mock('../../plugin-detail-panel/detail-header', () => ({
|
||||
default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
|
||||
<div data-testid="detail-header" data-is-readme-view={isReadmeView}>
|
||||
@ -32,10 +32,6 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Data Factories
|
||||
// ================================
|
||||
|
||||
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-plugin-id',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Test Utilities
|
||||
// ================================
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -105,7 +97,7 @@ const createQueryClient = () => new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@ -114,15 +106,23 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts
|
||||
// Store (useReadmePanelStore) tests moved to store.spec.ts
|
||||
// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx
|
||||
const openReadmePanel = (
|
||||
detail = createMockPluginDetail(),
|
||||
presentation: 'drawer' | 'dialog' = 'drawer',
|
||||
) => {
|
||||
useReadmePanelStore.getState().openReadmePanel({
|
||||
detail,
|
||||
presentation,
|
||||
triggerId: 'readme-trigger',
|
||||
})
|
||||
return detail
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ReadmePanel Component Tests
|
||||
// ================================
|
||||
describe('ReadmePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en-US'
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
@ -130,487 +130,114 @@ describe('ReadmePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should return null when no plugin detail is set', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
it('should return null when no readme panel is open', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render drawer presentation with plugin header content', () => {
|
||||
openReadmePanel()
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
|
||||
expect(screen.getByRole('dialog')).toHaveClass('data-[swipe-direction=left]:w-150')
|
||||
})
|
||||
|
||||
it('should render dialog presentation when requested', () => {
|
||||
openReadmePanel(createMockPluginDetail(), 'dialog')
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-w-200')
|
||||
})
|
||||
|
||||
it('should close the active panel when close button is clicked', () => {
|
||||
openReadmePanel()
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should render loading, error, empty, and readme states from the readme query', () => {
|
||||
openReadmePanel()
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
const { rerender } = renderWithQueryClient(<ReadmePanel />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
it('should render portal content when plugin detail is set', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
|
||||
|
||||
it('should render DetailHeader component', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
|
||||
it('should render close button', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
it('should call usePluginReadme with the plugin identifier and selected language', () => {
|
||||
openReadmePanel(createMockPluginDetail({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
}))
|
||||
|
||||
// ActionButton wraps the close icon
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Loading State Tests
|
||||
// ================================
|
||||
describe('Loading State', () => {
|
||||
it('should show loading indicator when isLoading is true', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
it('should pass undefined language for zh-Hans locale', () => {
|
||||
mockLanguage = 'zh-Hans'
|
||||
openReadmePanel(createMockPluginDetail({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
}))
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Loading component should be rendered with role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
language: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Error State Tests
|
||||
// ================================
|
||||
describe('Error State', () => {
|
||||
it('should show error message when error occurs', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
})
|
||||
it('should open correctly from ReadmeEntrance through the global host', () => {
|
||||
const detail = createMockPluginDetail()
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
renderWithQueryClient(
|
||||
<>
|
||||
<ReadmeEntrance pluginDetail={detail} />
|
||||
<ReadmePanel />
|
||||
</>,
|
||||
)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ }))
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// No Readme Available State Tests
|
||||
// ================================
|
||||
describe('No Readme Available', () => {
|
||||
it('should show no readme message when readme is empty', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no readme message when data is null', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Markdown Content Tests
|
||||
// ================================
|
||||
describe('Markdown Content', () => {
|
||||
it('should render markdown container when readme is available', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Markdown component container should be rendered
|
||||
// Note: The Markdown component uses dynamic import, so content may load asynchronously
|
||||
const markdownContainer = document.querySelector('.markdown-body')
|
||||
expect(markdownContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show error or no-readme message when readme is available', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Should not show error or no-readme message
|
||||
expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Rendering Tests (Drawer Mode)
|
||||
// ================================
|
||||
describe('Portal Rendering - Drawer Mode', () => {
|
||||
it('should render drawer styled container in drawer mode', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Drawer mode has specific max-width
|
||||
const drawerContainer = document.querySelector('.max-w-\\[600px\\]')
|
||||
expect(drawerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct drawer positioning classes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Check for drawer-specific classes
|
||||
const backdrop = document.querySelector('.justify-start')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Rendering Tests (Modal Mode)
|
||||
// ================================
|
||||
describe('Portal Rendering - Modal Mode', () => {
|
||||
it('should render modal styled container in modal mode', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Modal mode has different max-width
|
||||
const modalContainer = document.querySelector('.max-w-\\[800px\\]')
|
||||
expect(modalContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct modal positioning classes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Check for modal-specific classes
|
||||
const backdrop = document.querySelector('.items-center.justify-center')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions / Event Handlers
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should close panel when close button is clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should close panel when backdrop is clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the backdrop (outer div)
|
||||
const backdrop = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not close panel when content area is clicked', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the content container (should stop propagation)
|
||||
const contentContainer = document.querySelector('.pointer-events-auto')
|
||||
fireEvent.click(contentContainer!)
|
||||
|
||||
await waitFor(() => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not close panel when content area is clicked in modal mode', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the content container in modal mode (should stop propagation)
|
||||
const contentContainer = document.querySelector('.pointer-events-auto')
|
||||
fireEvent.click(contentContainer!)
|
||||
|
||||
await waitFor(() => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// API Call Tests
|
||||
// ================================
|
||||
describe('API Calls', () => {
|
||||
it('should call usePluginReadme with correct parameters', () => {
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass undefined language for zh-Hans locale', () => {
|
||||
// Set language to zh-Hans
|
||||
mockLanguage = 'zh-Hans'
|
||||
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// The component should pass undefined for language when zh-Hans
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
language: undefined,
|
||||
})
|
||||
|
||||
// Reset language
|
||||
mockLanguage = 'en-US'
|
||||
})
|
||||
|
||||
it('should handle empty plugin_unique_identifier', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: '',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: '',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle detail with missing declaration', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
// Simulate missing fields
|
||||
delete (mockDetail as Partial<PluginDetail>).declaration
|
||||
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// This should not throw
|
||||
expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle rapid open/close operations', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Rapidly toggle the panel
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
setCurrentPluginDetail()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('should handle switching between drawer and modal modes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Start with drawer
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
let state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
|
||||
|
||||
// Switch to modal
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('should handle undefined detail gracefully', () => {
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Set to undefined explicitly
|
||||
act(() => {
|
||||
setCurrentPluginDetail(undefined, ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Integration', () => {
|
||||
it('should work correctly when opened from ReadmeEntrance', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Integration Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Render both components
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<>
|
||||
<ReadmeEntrance pluginDetail={mockDetail} />
|
||||
<ReadmePanel />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Initially panel should not show content
|
||||
expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument()
|
||||
|
||||
// Click the entrance button
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Re-render to pick up store changes
|
||||
rerender(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ReadmeEntrance pluginDetail={mockDetail} />
|
||||
<ReadmePanel />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Panel should now show content
|
||||
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
|
||||
// Markdown content renders in a container (dynamic import may not render content synchronously)
|
||||
expect(document.querySelector('.markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct plugin information in header', () => {
|
||||
const mockDetail = createMockPluginDetail({
|
||||
name: 'my-awesome-plugin',
|
||||
})
|
||||
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,54 +1,52 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ReadmeShowType, useReadmePanelStore } from '../store'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
|
||||
describe('readme-panel/store', () => {
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPluginDetail: undefined })
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
})
|
||||
|
||||
it('initializes with undefined currentPluginDetail', () => {
|
||||
it('initializes without an active panel', () => {
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail).toBeUndefined()
|
||||
expect(state.currentPanel).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets current plugin detail with drawer showType by default', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
it('opens drawer presentation by default', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' })
|
||||
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
expect(useReadmePanelStore.getState().currentPanel).toEqual({
|
||||
detail,
|
||||
presentation: 'drawer',
|
||||
triggerId: 'readme-trigger',
|
||||
})
|
||||
})
|
||||
|
||||
it('sets current plugin detail with modal showType', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
it('opens dialog presentation when requested', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' })
|
||||
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
})
|
||||
|
||||
it('clears current plugin detail when called with undefined', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined()
|
||||
it('closes the active panel', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail })
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeDefined()
|
||||
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(undefined)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined()
|
||||
useReadmePanelStore.getState().closeReadmePanel()
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces previous detail with new one', () => {
|
||||
it('replaces the active panel with the latest request', () => {
|
||||
const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail
|
||||
const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail
|
||||
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail1)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1')
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail: detail1 })
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' })
|
||||
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
})
|
||||
})
|
||||
|
||||
81
web/app/components/plugins/readme-panel/content.tsx
Normal file
81
web/app/components/plugins/readme-panel/content.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginDetail } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { usePluginReadme } from '@/service/use-plugins'
|
||||
import DetailHeader from '../plugin-detail-panel/detail-header'
|
||||
|
||||
type ReadmePanelContentProps = {
|
||||
detail: PluginDetail
|
||||
title: ReactNode
|
||||
closeButton: ReactNode
|
||||
}
|
||||
|
||||
export function ReadmePanelContent({
|
||||
detail,
|
||||
title,
|
||||
closeButton,
|
||||
}: ReadmePanelContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const pluginUniqueIdentifier = detail.plugin_unique_identifier || ''
|
||||
|
||||
const { data: readmeData, isLoading, error } = usePluginReadme({
|
||||
plugin_unique_identifier: pluginUniqueIdentifier,
|
||||
language: language === 'zh-Hans' ? undefined : language,
|
||||
})
|
||||
|
||||
let readmeContent: ReactNode
|
||||
if (isLoading) {
|
||||
readmeContent = (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (error) {
|
||||
readmeContent = (
|
||||
<div className="py-8 text-center text-text-tertiary">
|
||||
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (readmeData?.readme) {
|
||||
readmeContent = (
|
||||
<Markdown
|
||||
content={readmeData.readme}
|
||||
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
readmeContent = (
|
||||
<div className="py-8 text-center text-text-tertiary">
|
||||
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 rounded-t-xl bg-background-body px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3 shrink-0 text-text-tertiary" />
|
||||
{title}
|
||||
</div>
|
||||
{closeButton}
|
||||
</div>
|
||||
<DetailHeader detail={detail} isReadmeView={true} />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
{readmeContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
web/app/components/plugins/readme-panel/dialog.tsx
Normal file
52
web/app/components/plugins/readme-panel/dialog.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReadmePanelContent } from './content'
|
||||
|
||||
type ReadmeDialogProps = {
|
||||
detail: PluginDetail
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerId?: string
|
||||
}
|
||||
|
||||
export function ReadmeDialog({
|
||||
detail,
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerId,
|
||||
}: ReadmeDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={triggerId}
|
||||
>
|
||||
<DialogContent className="h-[calc(100dvh-16px)] w-full max-w-200 overflow-hidden p-0">
|
||||
<ReadmePanelContent
|
||||
detail={detail}
|
||||
title={(
|
||||
<DialogTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
|
||||
{t('readmeInfo.title', { ns: 'plugin' })}
|
||||
</DialogTitle>
|
||||
)}
|
||||
closeButton={(
|
||||
<DialogCloseButton
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="static h-8 w-8 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
62
web/app/components/plugins/readme-panel/drawer.tsx
Normal file
62
web/app/components/plugins/readme-panel/drawer.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReadmePanelContent } from './content'
|
||||
|
||||
type ReadmeDrawerProps = {
|
||||
detail: PluginDetail
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerId?: string
|
||||
}
|
||||
|
||||
export function ReadmeDrawer({
|
||||
detail,
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerId,
|
||||
}: ReadmeDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={triggerId}
|
||||
modal
|
||||
swipeDirection="left"
|
||||
>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop className="bg-transparent" />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup className="data-[swipe-direction=left]:top-16 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-150 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-l-[0.5px]">
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0">
|
||||
<ReadmePanelContent
|
||||
detail={detail}
|
||||
title={(
|
||||
<DrawerTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
|
||||
{t('readmeInfo.title', { ns: 'plugin' })}
|
||||
</DrawerTitle>
|
||||
)}
|
||||
closeButton={(
|
||||
<DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} />
|
||||
)}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user