mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 06:41:10 +08:00
Merge branch 'feat/refine-snippet-siderbar' into deploy/dev
This commit is contained in:
commit
573afee1d1
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -151,5 +151,9 @@ jobs:
|
||||
run: |
|
||||
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
||||
|
||||
- name: Prune unused i18n
|
||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||
run: vp run dify-web#i18n:prune-unused --write
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||
|
||||
4
.github/workflows/style.yml
vendored
4
.github/workflows/style.yml
vendored
@ -109,6 +109,10 @@ jobs:
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: vp run knip:production
|
||||
|
||||
- name: Web production unused declarations check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: vp run knip:production-unused-check
|
||||
|
||||
ts-common-style:
|
||||
name: TS Common
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
@ -201,21 +201,23 @@ def _legacy_workspace_roles(
|
||||
This keeps the new `/rbac/roles` endpoint compatible with the original
|
||||
Dify role model when enterprise RBAC is disabled.
|
||||
"""
|
||||
|
||||
legacy_roles = [
|
||||
svc.RBACRole(
|
||||
id=role_name,
|
||||
tenant_id="",
|
||||
type=svc.RBACRoleType.WORKSPACE.value,
|
||||
category="global_system_default",
|
||||
name=role_name,
|
||||
description="",
|
||||
is_builtin=True,
|
||||
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
|
||||
role_tag="owner" if role_name == "owner" else "",
|
||||
legacy_roles = []
|
||||
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator"):
|
||||
if not dify_config.DATASET_OPERATOR_ENABLED and role_name == "dataset_operator":
|
||||
continue
|
||||
legacy_roles.append(
|
||||
svc.RBACRole(
|
||||
id=role_name,
|
||||
tenant_id="",
|
||||
type=svc.RBACRoleType.WORKSPACE.value,
|
||||
category="global_system_default",
|
||||
name=role_name,
|
||||
description="",
|
||||
is_builtin=True,
|
||||
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
|
||||
role_tag="owner" if role_name == "owner" else "",
|
||||
)
|
||||
)
|
||||
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
|
||||
]
|
||||
|
||||
if not include_owner:
|
||||
legacy_roles = [r for r in legacy_roles if r.name != "owner"]
|
||||
|
||||
@ -195,16 +195,16 @@ class TestTencentDataTrace:
|
||||
mock_proc.assert_called_once_with(trace_info, 123)
|
||||
mock_dur.assert_called_once_with(trace_info)
|
||||
|
||||
def test_workflow_trace_exception(self, tencent_data_trace):
|
||||
def test_workflow_trace_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.workflow_run_id = "run-id"
|
||||
|
||||
with patch(
|
||||
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
|
||||
):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace.workflow_trace(trace_info)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow trace")
|
||||
assert "[Tencent APM] Failed to process workflow trace" in caplog.text
|
||||
|
||||
def test_message_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
|
||||
trace_info = MagicMock(spec=MessageTraceInfo)
|
||||
@ -228,15 +228,15 @@ class TestTencentDataTrace:
|
||||
mock_metrics.assert_called_once_with(trace_info)
|
||||
mock_dur.assert_called_once_with(trace_info)
|
||||
|
||||
def test_message_trace_exception(self, tencent_data_trace):
|
||||
def test_message_trace_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=MessageTraceInfo)
|
||||
|
||||
with patch(
|
||||
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
|
||||
):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace.message_trace(trace_info)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process message trace")
|
||||
assert "[Tencent APM] Failed to process message trace" in caplog.text
|
||||
|
||||
def test_tool_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
|
||||
trace_info = MagicMock(spec=ToolTraceInfo)
|
||||
@ -259,16 +259,16 @@ class TestTencentDataTrace:
|
||||
tencent_data_trace.tool_trace(trace_info)
|
||||
tencent_data_trace.trace_client.add_span.assert_not_called()
|
||||
|
||||
def test_tool_trace_exception(self, tencent_data_trace):
|
||||
def test_tool_trace_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=ToolTraceInfo)
|
||||
trace_info.message_id = "msg-id"
|
||||
|
||||
with patch(
|
||||
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
|
||||
):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace.tool_trace(trace_info)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process tool trace")
|
||||
assert "[Tencent APM] Failed to process tool trace" in caplog.text
|
||||
|
||||
def test_dataset_retrieval_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
|
||||
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
|
||||
@ -291,29 +291,30 @@ class TestTencentDataTrace:
|
||||
tencent_data_trace.dataset_retrieval_trace(trace_info)
|
||||
tencent_data_trace.trace_client.add_span.assert_not_called()
|
||||
|
||||
def test_dataset_retrieval_trace_exception(self, tencent_data_trace):
|
||||
def test_dataset_retrieval_trace_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
|
||||
trace_info.message_id = "msg-id"
|
||||
|
||||
with patch(
|
||||
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
|
||||
):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace.dataset_retrieval_trace(trace_info)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process dataset retrieval trace")
|
||||
assert "[Tencent APM] Failed to process dataset retrieval trace" in caplog.text
|
||||
|
||||
def test_suggested_question_trace(self, tencent_data_trace):
|
||||
def test_suggested_question_trace(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.info") as mock_log:
|
||||
with caplog.at_level(logging.INFO):
|
||||
tencent_data_trace.suggested_question_trace(trace_info)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Processing suggested question trace")
|
||||
assert "[Tencent APM] Processing suggested question trace" in caplog.text
|
||||
|
||||
def test_suggested_question_trace_exception(self, tencent_data_trace):
|
||||
def test_suggested_question_trace_exception(self, tencent_data_trace, monkeypatch, caplog):
|
||||
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.info", side_effect=Exception("error")):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
tencent_data_trace.suggested_question_trace(trace_info)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process suggested question trace")
|
||||
target_logger = logging.getLogger("dify_trace_tencent.tencent_trace")
|
||||
monkeypatch.setattr(target_logger, "info", MagicMock(side_effect=Exception("error")))
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace.suggested_question_trace(trace_info)
|
||||
assert "[Tencent APM] Failed to process suggested question trace" in caplog.text
|
||||
|
||||
def test_process_workflow_nodes(self, tencent_data_trace, mock_trace_utils):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
@ -335,7 +336,7 @@ class TestTencentDataTrace:
|
||||
assert tencent_data_trace.trace_client.add_span.call_count == 2
|
||||
mock_metrics.assert_called_once_with(node1)
|
||||
|
||||
def test_process_workflow_nodes_node_exception(self, tencent_data_trace, mock_trace_utils):
|
||||
def test_process_workflow_nodes_node_exception(self, tencent_data_trace, mock_trace_utils, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
mock_trace_utils.convert_to_span_id.return_value = 111
|
||||
|
||||
@ -344,18 +345,17 @@ class TestTencentDataTrace:
|
||||
|
||||
with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]):
|
||||
with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace._process_workflow_nodes(trace_info, 123)
|
||||
# The exception should be caught by the outer handler since convert_to_span_id is called first
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes")
|
||||
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
|
||||
|
||||
def test_process_workflow_nodes_exception(self, tencent_data_trace, mock_trace_utils):
|
||||
def test_process_workflow_nodes_exception(self, tencent_data_trace, mock_trace_utils, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
mock_trace_utils.convert_to_span_id.side_effect = Exception("outer error")
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace._process_workflow_nodes(trace_info, 123)
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes")
|
||||
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
|
||||
|
||||
def test_build_workflow_node_span(self, tencent_data_trace, mock_span_builder):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
@ -377,16 +377,16 @@ class TestTencentDataTrace:
|
||||
assert result == "span"
|
||||
builder_method.assert_called_once_with(123, 456, trace_info, node)
|
||||
|
||||
def test_build_workflow_node_span_exception(self, tencent_data_trace, mock_span_builder):
|
||||
def test_build_workflow_node_span_exception(self, tencent_data_trace, mock_span_builder, caplog):
|
||||
node = MagicMock(spec=WorkflowNodeExecution)
|
||||
node.node_type = BuiltinNodeTypes.LLM
|
||||
node.id = "n1"
|
||||
mock_span_builder.build_workflow_llm_span.side_effect = Exception("error")
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
result = tencent_data_trace._build_workflow_node_span(node, 123, MagicMock(), 456)
|
||||
assert result is None
|
||||
mock_log.assert_called_once()
|
||||
assert result is None
|
||||
assert len([r for r in caplog.records if r.levelno == logging.DEBUG]) >= 1
|
||||
|
||||
def test_get_workflow_node_executions(self, tencent_data_trace):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
@ -419,16 +419,16 @@ class TestTencentDataTrace:
|
||||
assert results == mock_executions
|
||||
account.set_tenant_id.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace):
|
||||
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.metadata = {}
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
results = tencent_data_trace._get_workflow_node_executions(trace_info)
|
||||
assert results == []
|
||||
mock_log.assert_called_once()
|
||||
assert results == []
|
||||
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
|
||||
|
||||
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace):
|
||||
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.metadata = {"app_id": "app-1"}
|
||||
|
||||
@ -439,10 +439,10 @@ class TestTencentDataTrace:
|
||||
session = mock_session_ctx.return_value.__enter__.return_value
|
||||
session.scalar.return_value = None
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
results = tencent_data_trace._get_workflow_node_executions(trace_info)
|
||||
assert results == []
|
||||
mock_log.assert_called_once()
|
||||
assert results == []
|
||||
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
|
||||
|
||||
def test_get_user_id_workflow(self, tencent_data_trace):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
@ -471,16 +471,16 @@ class TestTencentDataTrace:
|
||||
user_id = tencent_data_trace._get_user_id(trace_info)
|
||||
assert user_id == "anonymous"
|
||||
|
||||
def test_get_user_id_exception(self, tencent_data_trace):
|
||||
def test_get_user_id_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.tenant_id = "t"
|
||||
trace_info.metadata = {"user_id": "u"}
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")):
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
user_id = tencent_data_trace._get_user_id(trace_info)
|
||||
assert user_id == "unknown"
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to get user ID")
|
||||
assert user_id == "unknown"
|
||||
assert "[Tencent APM] Failed to get user ID" in caplog.text
|
||||
|
||||
def test_record_llm_metrics_usage_in_process_data(self, tencent_data_trace):
|
||||
node = MagicMock(spec=WorkflowNodeExecution)
|
||||
@ -514,14 +514,14 @@ class TestTencentDataTrace:
|
||||
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
|
||||
tencent_data_trace.trace_client.record_token_usage.assert_called_once()
|
||||
|
||||
def test_record_llm_metrics_exception(self, tencent_data_trace):
|
||||
def test_record_llm_metrics_exception(self, tencent_data_trace, caplog):
|
||||
node = MagicMock(spec=WorkflowNodeExecution)
|
||||
node.process_data = None
|
||||
node.outputs = None
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
tencent_data_trace._record_llm_metrics(node)
|
||||
# Should not crash
|
||||
# Should not crash
|
||||
|
||||
def test_record_message_llm_metrics(self, tencent_data_trace):
|
||||
trace_info = MagicMock(spec=MessageTraceInfo)
|
||||
@ -553,13 +553,13 @@ class TestTencentDataTrace:
|
||||
tencent_data_trace._record_message_llm_metrics(trace_info)
|
||||
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
|
||||
|
||||
def test_record_message_llm_metrics_exception(self, tencent_data_trace):
|
||||
def test_record_message_llm_metrics_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=MessageTraceInfo)
|
||||
trace_info.metadata = None
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
tencent_data_trace._record_message_llm_metrics(trace_info)
|
||||
# Should not crash
|
||||
# Should not crash
|
||||
|
||||
def test_record_workflow_trace_duration(self, tencent_data_trace):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
@ -605,11 +605,11 @@ class TestTencentDataTrace:
|
||||
attributes = kwargs["attributes"] if "attributes" in kwargs else args[1] if len(args) > 1 else {}
|
||||
assert attributes["has_conversation"] == "false"
|
||||
|
||||
def test_record_workflow_trace_duration_exception(self, tencent_data_trace):
|
||||
def test_record_workflow_trace_duration_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.start_time = MagicMock() # This might cause total_seconds() to fail if not mocked right
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
tencent_data_trace._record_workflow_trace_duration(trace_info)
|
||||
|
||||
def test_record_message_trace_duration(self, tencent_data_trace):
|
||||
@ -627,11 +627,11 @@ class TestTencentDataTrace:
|
||||
2.0, {"conversation_mode": "chat", "stream": "true"}
|
||||
)
|
||||
|
||||
def test_record_message_trace_duration_exception(self, tencent_data_trace):
|
||||
def test_record_message_trace_duration_exception(self, tencent_data_trace, caplog):
|
||||
trace_info = MagicMock(spec=MessageTraceInfo)
|
||||
trace_info.start_time = None
|
||||
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
tencent_data_trace._record_message_trace_duration(trace_info)
|
||||
|
||||
def test_close(self, tencent_data_trace):
|
||||
@ -647,11 +647,11 @@ class TestTencentDataTrace:
|
||||
|
||||
client.shutdown.assert_called_once()
|
||||
|
||||
def test_close_exception(self, tencent_data_trace):
|
||||
def test_close_exception(self, tencent_data_trace, caplog):
|
||||
tencent_data_trace.trace_client.shutdown.side_effect = Exception("error")
|
||||
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
tencent_data_trace.close()
|
||||
mock_log.assert_called_once_with("[Tencent APM] Failed to shutdown trace client during cleanup")
|
||||
assert "[Tencent APM] Failed to shutdown trace client during cleanup" in caplog.text
|
||||
|
||||
def test_close_handles_async_shutdown_mock(self, tencent_data_trace):
|
||||
shutdown = AsyncMock()
|
||||
|
||||
@ -113,7 +113,7 @@ class LindormVectorStore(BaseVector):
|
||||
)
|
||||
def _bulk_with_retry(actions):
|
||||
try:
|
||||
response = self._client.bulk(actions, timeout=timeout)
|
||||
response = self._client.bulk(body=actions, timeout=timeout)
|
||||
if response["errors"]:
|
||||
error_items = [item for item in response["items"] if "error" in item["index"]]
|
||||
error_msg = f"Bulk indexing had {len(error_items)} errors"
|
||||
@ -231,7 +231,7 @@ class LindormVectorStore(BaseVector):
|
||||
routing_filter_query = {
|
||||
"query": {"bool": {"must": [{"term": {f"{ROUTING_FIELD}.keyword": self._routing}}]}}
|
||||
}
|
||||
self._client.delete_by_query(self._collection_name, body=routing_filter_query)
|
||||
self._client.delete_by_query(index=self._collection_name, body=routing_filter_query)
|
||||
self.refresh()
|
||||
else:
|
||||
if self._client.indices.exists(index=self._collection_name):
|
||||
|
||||
@ -127,7 +127,7 @@ def test_create_refresh_and_add_texts_success(lindorm_module, monkeypatch: pytes
|
||||
vector.add_texts(docs, embeddings, batch_size=2, timeout=9)
|
||||
|
||||
assert vector._client.bulk.call_count == 2
|
||||
actions = vector._client.bulk.call_args_list[0].args[0]
|
||||
actions = vector._client.bulk.call_args_list[0].kwargs["body"]
|
||||
assert actions[0]["index"]["routing"] == "route"
|
||||
assert actions[1][lindorm_module.ROUTING_FIELD] == "route"
|
||||
vector.refresh()
|
||||
|
||||
@ -379,6 +379,9 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"snippets.create_and_modify",
|
||||
"tool.manage",
|
||||
"snippets.create_and_modify",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
"billing.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
@ -386,6 +389,9 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
"app_library.access",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
"billing.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
@ -834,6 +840,7 @@ class RBACService:
|
||||
options: ListOption | None = None,
|
||||
) -> Paginated[RBACRole]:
|
||||
params = (options or ListOption()).to_params({"include_owner": include_owner})
|
||||
params["dataset_operator_enabled"] = dify_config.DATASET_OPERATOR_ENABLED
|
||||
data = _inner_call(
|
||||
"GET",
|
||||
f"{_INNER_PREFIX}/roles",
|
||||
|
||||
@ -9,6 +9,7 @@ This module tests the core authentication endpoints including:
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@ -191,7 +192,7 @@ class TestLoginApi:
|
||||
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
|
||||
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
|
||||
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
|
||||
def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask):
|
||||
def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog):
|
||||
"""
|
||||
Test login rejection when rate limit is exceeded.
|
||||
|
||||
@ -204,22 +205,24 @@ class TestLoginApi:
|
||||
mock_get_invitation.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(EmailPasswordLoginLimitError):
|
||||
login_api.post()
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(EmailPasswordLoginLimitError):
|
||||
login_api.post()
|
||||
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "test@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.LOGIN_RATE_LIMITED
|
||||
warn_records = [
|
||||
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
assert warn_records[0].args[0] == "test@example.com"
|
||||
assert warn_records[0].args[1] == LoginFailureReason.LOGIN_RATE_LIMITED
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True)
|
||||
@patch("controllers.console.auth.login.BillingService.is_email_in_freeze")
|
||||
def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask):
|
||||
def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask, caplog):
|
||||
"""
|
||||
Test login rejection for frozen accounts.
|
||||
|
||||
@ -231,17 +234,19 @@ class TestLoginApi:
|
||||
mock_is_frozen.return_value = True
|
||||
|
||||
# Act & Assert
|
||||
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AccountInFreezeError):
|
||||
login_api.post()
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AccountInFreezeError):
|
||||
login_api.post()
|
||||
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "frozen@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_IN_FREEZE
|
||||
warn_records = [
|
||||
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
assert warn_records[0].args[0] == "frozen@example.com"
|
||||
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_IN_FREEZE
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
|
||||
@ -257,6 +262,7 @@ class TestLoginApi:
|
||||
mock_is_rate_limit,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
caplog,
|
||||
):
|
||||
"""
|
||||
Test login failure with invalid credentials.
|
||||
@ -272,20 +278,22 @@ class TestLoginApi:
|
||||
mock_authenticate.side_effect = AccountPasswordError("Invalid password")
|
||||
|
||||
# Act & Assert
|
||||
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
|
||||
with app.test_request_context(
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "password": encode_password("WrongPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AuthenticationFailedError):
|
||||
login_api.post()
|
||||
with app.test_request_context(
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "password": encode_password("WrongPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AuthenticationFailedError):
|
||||
login_api.post()
|
||||
|
||||
mock_add_rate_limit.assert_called_once_with("test@example.com")
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "test@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS
|
||||
warn_records = [
|
||||
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
assert warn_records[0].args[0] == "test@example.com"
|
||||
assert warn_records[0].args[1] == LoginFailureReason.INVALID_CREDENTIALS
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
|
||||
@ -293,7 +301,7 @@ class TestLoginApi:
|
||||
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
|
||||
@patch("controllers.console.auth.login.AccountService.authenticate")
|
||||
def test_login_fails_for_banned_account(
|
||||
self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask
|
||||
self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog
|
||||
):
|
||||
"""
|
||||
Test login rejection for banned accounts.
|
||||
@ -308,19 +316,21 @@ class TestLoginApi:
|
||||
mock_authenticate.side_effect = AccountLoginError("Account is banned")
|
||||
|
||||
# Act & Assert
|
||||
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
|
||||
with app.test_request_context(
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "banned@example.com", "password": encode_password("ValidPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AccountBannedError):
|
||||
login_api.post()
|
||||
with app.test_request_context(
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "banned@example.com", "password": encode_password("ValidPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AccountBannedError):
|
||||
login_api.post()
|
||||
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "banned@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
|
||||
warn_records = [
|
||||
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
assert warn_records[0].args[0] == "banned@example.com"
|
||||
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
|
||||
@ -452,23 +462,26 @@ class TestLoginApi:
|
||||
mock_get_token_data: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
app: Flask,
|
||||
caplog,
|
||||
):
|
||||
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
|
||||
mock_get_account.side_effect = Unauthorized("Account is banned.")
|
||||
|
||||
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
|
||||
):
|
||||
with pytest.raises(AccountBannedError):
|
||||
EmailCodeLoginApi().post()
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
|
||||
):
|
||||
with pytest.raises(AccountBannedError):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "user@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
|
||||
warn_records = [
|
||||
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
assert warn_records[0].args[0] == "user@example.com"
|
||||
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
|
||||
|
||||
|
||||
class TestLogoutApi:
|
||||
|
||||
@ -201,10 +201,10 @@ class TestPaginationMapping:
|
||||
},
|
||||
]
|
||||
assert response["pagination"] == {
|
||||
"total_count": 5,
|
||||
"total_count": 4,
|
||||
"per_page": 2,
|
||||
"current_page": 1,
|
||||
"total_pages": 3,
|
||||
"total_pages": 2,
|
||||
}
|
||||
mock_list.assert_not_called()
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@ -16,6 +17,13 @@ def encode_code(code: str) -> str:
|
||||
return base64.b64encode(code.encode("utf-8")).decode()
|
||||
|
||||
|
||||
def assert_login_failure_logged(caplog: pytest.LogCaptureFixture, email: str, reason: LoginFailureReason) -> None:
|
||||
records = [record for record in caplog.records if record.name == "controllers.web.login"]
|
||||
assert len(records) == 1
|
||||
assert records[0].args[0] == email
|
||||
assert records[0].args[1] == reason
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
flask_app = Flask(__name__)
|
||||
@ -114,10 +122,10 @@ class TestLoginApi:
|
||||
"controllers.web.login.WebAppAuthService.authenticate",
|
||||
side_effect=services.errors.account.AccountLoginError(),
|
||||
)
|
||||
def test_login_banned_account(self, mock_auth: MagicMock, app: Flask) -> None:
|
||||
def test_login_banned_account(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
|
||||
from controllers.console.error import AccountBannedError
|
||||
|
||||
with patch("controllers.web.login.logger.warning") as mock_log_warning:
|
||||
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
|
||||
with app.test_request_context(
|
||||
"/web/login",
|
||||
method="POST",
|
||||
@ -126,18 +134,16 @@ class TestLoginApi:
|
||||
with pytest.raises(AccountBannedError):
|
||||
LoginApi().post()
|
||||
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "user@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
|
||||
assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.ACCOUNT_BANNED)
|
||||
|
||||
@patch(
|
||||
"controllers.web.login.WebAppAuthService.authenticate",
|
||||
side_effect=services.errors.account.AccountPasswordError(),
|
||||
)
|
||||
def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask) -> None:
|
||||
def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
|
||||
from controllers.console.auth.error import AuthenticationFailedError
|
||||
|
||||
with patch("controllers.web.login.logger.warning") as mock_log_warning:
|
||||
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
|
||||
with app.test_request_context(
|
||||
"/web/login",
|
||||
method="POST",
|
||||
@ -146,18 +152,16 @@ class TestLoginApi:
|
||||
with pytest.raises(AuthenticationFailedError):
|
||||
LoginApi().post()
|
||||
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "user@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS
|
||||
assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.INVALID_CREDENTIALS)
|
||||
|
||||
@patch(
|
||||
"controllers.web.login.WebAppAuthService.authenticate",
|
||||
side_effect=services.errors.account.AccountNotFoundError(),
|
||||
)
|
||||
def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask) -> None:
|
||||
def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
|
||||
from controllers.console.auth.error import AuthenticationFailedError
|
||||
|
||||
with patch("controllers.web.login.logger.warning") as mock_log_warning:
|
||||
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
|
||||
with app.test_request_context(
|
||||
"/web/login",
|
||||
method="POST",
|
||||
@ -166,13 +170,13 @@ class TestLoginApi:
|
||||
with pytest.raises(AuthenticationFailedError):
|
||||
LoginApi().post()
|
||||
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "missing@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_NOT_FOUND
|
||||
assert_login_failure_logged(caplog, "missing@example.com", LoginFailureReason.ACCOUNT_NOT_FOUND)
|
||||
|
||||
@patch("controllers.web.login.WebAppAuthService.get_email_code_login_data", return_value=None)
|
||||
def test_email_code_login_logs_invalid_token(self, mock_get_token_data: MagicMock, app: Flask) -> None:
|
||||
with patch("controllers.web.login.logger.warning") as mock_log_warning:
|
||||
def test_email_code_login_logs_invalid_token(
|
||||
self, mock_get_token_data: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
|
||||
with app.test_request_context(
|
||||
"/web/email-code-login/validity",
|
||||
method="POST",
|
||||
@ -182,9 +186,7 @@ class TestLoginApi:
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_get_token_data.assert_called_once_with("token-123")
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "user@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_EMAIL_CODE_TOKEN
|
||||
assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||
|
||||
@patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token")
|
||||
@patch(
|
||||
@ -201,10 +203,11 @@ class TestLoginApi:
|
||||
mock_get_user: MagicMock,
|
||||
mock_revoke_token: MagicMock,
|
||||
app: Flask,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
from controllers.console.error import AccountBannedError
|
||||
|
||||
with patch("controllers.web.login.logger.warning") as mock_log_warning:
|
||||
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
|
||||
with app.test_request_context(
|
||||
"/web/email-code-login/validity",
|
||||
method="POST",
|
||||
@ -215,9 +218,7 @@ class TestLoginApi:
|
||||
|
||||
mock_get_token_data.assert_called_once_with("token-123")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
assert mock_log_warning.call_count == 1
|
||||
assert mock_log_warning.call_args.args[1] == "user@example.com"
|
||||
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
|
||||
assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.ACCOUNT_BANNED)
|
||||
|
||||
|
||||
class TestLoginStatusApi:
|
||||
|
||||
@ -86,21 +86,26 @@ class TestRoles:
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/roles"
|
||||
assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"}
|
||||
assert call.params == {
|
||||
"dataset_operator_enabled": False,
|
||||
"page_number": 2,
|
||||
"results_per_page": 50,
|
||||
"reverse": "true",
|
||||
}
|
||||
assert out.pagination
|
||||
assert out.pagination.total_count == 1
|
||||
|
||||
def test_list_omits_params_when_default(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": [], "pagination": None}
|
||||
svc.RBACService.Roles.list("tenant-1")
|
||||
assert _call_args(mock_send).params is None
|
||||
assert _call_args(mock_send).params is not None
|
||||
|
||||
def test_list_forwards_include_owner(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": [], "pagination": None}
|
||||
|
||||
svc.RBACService.Roles.list("tenant-1", include_owner=1)
|
||||
|
||||
assert _call_args(mock_send).params == {"include_owner": 1}
|
||||
assert _call_args(mock_send).params == {"dataset_operator_enabled": False, "include_owner": 1}
|
||||
|
||||
def test_list_coerces_null_permission_keys(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
|
||||
@ -474,11 +474,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/base/var-highlight/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@ -1068,34 +1063,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/block-input/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/block-input/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
},
|
||||
"react/no-nested-component-definitions": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"react/static-components": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/carousel/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
@ -1387,7 +1354,7 @@
|
||||
},
|
||||
"web/app/components/base/error-boundary/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"react/jsx-no-key-after-spread": {
|
||||
"count": 1
|
||||
@ -2610,11 +2577,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/retrieval-method-info/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@ -3089,19 +3051,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
|
||||
"ts/no-non-null-asserted-optional-chain": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 6
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/metadata/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
@ -3325,7 +3274,7 @@
|
||||
},
|
||||
"web/app/components/develop/code.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 7
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/app/components/develop/doc.tsx": {
|
||||
@ -3337,9 +3286,6 @@
|
||||
"jsx-a11y/no-redundant-roles": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -5121,10 +5067,10 @@
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/layout/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 7
|
||||
"count": 6
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 7
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
|
||||
@ -5486,9 +5432,6 @@
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -7177,11 +7120,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-tools.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -7286,7 +7224,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 26
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"web/service/datasets.ts": {
|
||||
@ -7294,7 +7232,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/service/debug.ts": {
|
||||
|
||||
@ -123,9 +123,13 @@ describe('Switch', () => {
|
||||
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
|
||||
})
|
||||
|
||||
it('should have focus-visible ring-3 styles', async () => {
|
||||
it('should replace the native focus outline with the accent focus ring', async () => {
|
||||
const screen = await render(<Switch checked={false} />)
|
||||
await expect.element(screen.getByRole('switch')).toHaveClass('focus-visible:ring-2')
|
||||
await expect.element(screen.getByRole('switch')).toHaveClass(
|
||||
'outline-hidden',
|
||||
'focus-visible:ring-2',
|
||||
'focus-visible:ring-state-accent-solid',
|
||||
)
|
||||
})
|
||||
|
||||
it('should respect prefers-reduced-motion', async () => {
|
||||
|
||||
@ -10,7 +10,7 @@ import { cn } from '../cn'
|
||||
const switchRootStateClassName = 'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-checked:bg-components-toggle-bg data-checked:hover:bg-components-toggle-bg-hover data-disabled:cursor-not-allowed data-disabled:bg-components-toggle-bg-unchecked-disabled data-disabled:hover:bg-components-toggle-bg-unchecked-disabled data-disabled:data-checked:bg-components-toggle-bg-disabled data-disabled:data-checked:hover:bg-components-toggle-bg-disabled'
|
||||
|
||||
const switchRootVariants = cva(
|
||||
`group relative inline-flex shrink-0 cursor-pointer touch-manipulation items-center transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none ${switchRootStateClassName}`,
|
||||
`group relative inline-flex shrink-0 cursor-pointer touch-manipulation items-center outline-hidden transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none ${switchRootStateClassName}`,
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
/**
|
||||
* Integration test: DSL export/import flow
|
||||
*
|
||||
* Validates DSL export logic (sync draft → check secrets → download)
|
||||
* and DSL import modal state management.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
|
||||
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
|
||||
const mockNotify = vi.fn()
|
||||
const mockToast = {
|
||||
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
|
||||
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
|
||||
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
|
||||
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
pipelineId: 'pipeline-abc',
|
||||
knowledgeName: 'My Pipeline',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipelineConfig,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DSL Export/Import Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Export Flow', () => {
|
||||
it('should sync draft then export then download', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: false,
|
||||
})
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'My Pipeline.pipeline',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should export with include flag when specified', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify on export error', async () => {
|
||||
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export Check Flow', () => {
|
||||
it('should export directly when no secret environment variables', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should proceed to export directly (no secret vars)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'secret', key: 'API_KEY', value: '***' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ value_type: 'secret' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should notify on export check error', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,60 +0,0 @@
|
||||
/**
|
||||
* XSS Prevention Test Suite
|
||||
*
|
||||
* This test verifies that the XSS vulnerability in block-input has been properly
|
||||
* fixed by replacing dangerouslySetInnerHTML with safe React rendering.
|
||||
*/
|
||||
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import BlockInput from '../app/components/base/block-input'
|
||||
|
||||
// Mock styles
|
||||
vi.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
|
||||
default: {
|
||||
item: 'mock-item-class',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('XSS Prevention - Block Input Security', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('BlockInput Component Security', () => {
|
||||
it('should safely render malicious variable names without executing scripts', () => {
|
||||
const testInput = 'user@test.com{{<script>alert("XSS")</script>}}'
|
||||
const { container } = render(<BlockInput value={testInput} readonly={true} />)
|
||||
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('<script>')
|
||||
})
|
||||
|
||||
it('should preserve legitimate variable highlighting', () => {
|
||||
const legitimateInput = 'Hello {{userName}} welcome to {{appName}}'
|
||||
const { container } = render(<BlockInput value={legitimateInput} readonly={true} />)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('userName')
|
||||
expect(textContent).toContain('appName')
|
||||
})
|
||||
})
|
||||
|
||||
describe('React Automatic Escaping Verification', () => {
|
||||
it('should confirm React automatic escaping works correctly', () => {
|
||||
const TestComponent = () => <span>{'<script>alert("xss")</script>'}</span>
|
||||
const { container } = render(<TestComponent />)
|
||||
|
||||
const spanElement = container.querySelector('span')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
|
||||
expect(spanElement?.textContent).toBe('<script>alert("xss")</script>')
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@ -1,168 +0,0 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppInfo from '..'
|
||||
|
||||
const mockDetailPanel = vi.hoisted(() => vi.fn())
|
||||
const mockModals = vi.hoisted(() => vi.fn())
|
||||
|
||||
let mockAppPermissionKeys = ['app.acl.view_layout']
|
||||
const mockSetPanelOpen = vi.fn()
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
workspacePermissionKeys: ['app.create_and_management'],
|
||||
}),
|
||||
useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['app.create_and_management'],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../app-info-trigger', () => ({
|
||||
default: React.memo(({ appDetail, expand, onClick }: {
|
||||
appDetail: App & Partial<AppSSO>
|
||||
expand: boolean
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button type="button" data-testid="trigger" data-expand={expand} onClick={onClick}>
|
||||
{appDetail.name}
|
||||
</button>
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('../app-info-detail-panel', () => ({
|
||||
default: React.memo((props: { show: boolean, onClose: () => void }) => {
|
||||
mockDetailPanel(props)
|
||||
return props.show ? <div data-testid="detail-panel"><button type="button" onClick={props.onClose}>Close Panel</button></div> : null
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../app-info-modals', () => ({
|
||||
default: React.memo((props: { activeModal: string | null }) => {
|
||||
mockModals(props)
|
||||
return props.activeModal ? <div data-testid="modals" data-modal={props.activeModal} /> : null
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockAppDetail: App & Partial<AppSSO> = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
description: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
permission_keys: mockAppPermissionKeys,
|
||||
} as App & Partial<AppSSO>
|
||||
|
||||
const mockUseAppInfoActions = {
|
||||
appDetail: mockAppDetail,
|
||||
panelOpen: false,
|
||||
setPanelOpen: mockSetPanelOpen,
|
||||
closePanel: vi.fn(),
|
||||
activeModal: null as string | null,
|
||||
openModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
secretEnvList: [],
|
||||
setSecretEnvList: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
exportCheck: vi.fn(),
|
||||
handleConfirmExport: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../use-app-info-actions', () => ({
|
||||
useAppInfoActions: () => mockUseAppInfoActions,
|
||||
}))
|
||||
|
||||
describe('AppInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppPermissionKeys = ['app.acl.view_layout']
|
||||
mockUseAppInfoActions.appDetail = mockAppDetail
|
||||
mockUseAppInfoActions.appDetail.permission_keys = mockAppPermissionKeys
|
||||
mockUseAppInfoActions.panelOpen = false
|
||||
mockUseAppInfoActions.activeModal = null
|
||||
})
|
||||
|
||||
it('should return null when appDetail is not available', () => {
|
||||
mockUseAppInfoActions.appDetail = undefined as unknown as App & Partial<AppSSO>
|
||||
const { container } = render(<AppInfo expand />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should render trigger when not onlyShowDetail', () => {
|
||||
render(<AppInfo expand />)
|
||||
expect(screen.getByTestId('trigger'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not mount detail layer while the app info panel is closed', () => {
|
||||
render(<AppInfo expand />)
|
||||
expect(mockDetailPanel).not.toHaveBeenCalled()
|
||||
expect(mockModals).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render trigger when onlyShowDetail is true', () => {
|
||||
render(<AppInfo expand onlyShowDetail />)
|
||||
expect(screen.queryByTestId('trigger')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass expand prop to trigger', () => {
|
||||
render(<AppInfo expand />)
|
||||
expect(screen.getByTestId('trigger'))!.toHaveAttribute('data-expand', 'true')
|
||||
|
||||
const { unmount } = render(<AppInfo expand={false} />)
|
||||
const triggers = screen.getAllByTestId('trigger')
|
||||
expect(triggers[triggers.length - 1])!.toHaveAttribute('data-expand', 'false')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should toggle panel when trigger is clicked and user is editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppInfo expand />)
|
||||
|
||||
await user.click(screen.getByTestId('trigger'))
|
||||
|
||||
expect(mockSetPanelOpen).toHaveBeenCalled()
|
||||
const updater = mockSetPanelOpen.mock.calls[0]![0] as (v: boolean) => boolean
|
||||
expect(updater(false)).toBe(true)
|
||||
expect(updater(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not toggle panel when app ACL does not allow layout access', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAppPermissionKeys = []
|
||||
mockUseAppInfoActions.appDetail.permission_keys = mockAppPermissionKeys
|
||||
render(<AppInfo expand />)
|
||||
|
||||
await user.click(screen.getByTestId('trigger'))
|
||||
|
||||
expect(mockSetPanelOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show detail panel based on panelOpen when not onlyShowDetail', () => {
|
||||
mockUseAppInfoActions.panelOpen = true
|
||||
render(<AppInfo expand />)
|
||||
expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument()
|
||||
expect(mockDetailPanel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show detail panel based on openState when onlyShowDetail', () => {
|
||||
render(<AppInfo expand onlyShowDetail openState />)
|
||||
expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide detail panel when openState is false and onlyShowDetail', () => {
|
||||
render(<AppInfo expand onlyShowDetail openState={false} />)
|
||||
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
|
||||
expect(mockDetailPanel).not.toHaveBeenCalled()
|
||||
expect(mockModals).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -5,7 +5,6 @@ import { getAppACLCapabilities } from '@/utils/permission'
|
||||
import AppInfoDetailPanel from './app-info-detail-panel'
|
||||
import AppInfoModals from './app-info-modals'
|
||||
import AppInfoTrigger from './app-info-trigger'
|
||||
import { useAppInfoActions } from './use-app-info-actions'
|
||||
|
||||
type IAppInfoProps = {
|
||||
expand: boolean
|
||||
@ -122,16 +121,3 @@ export const AppInfoView = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AppInfo = ({ onDetailExpand, ...props }: IAppInfoProps) => {
|
||||
const actions = useAppInfoActions({ onDetailExpand })
|
||||
|
||||
return (
|
||||
<AppInfoView
|
||||
{...props}
|
||||
actions={actions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppInfo)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VarHighlight, { varHighlightHTML } from '../index'
|
||||
import VarHighlight from '../index'
|
||||
|
||||
describe('VarHighlight', () => {
|
||||
beforeEach(() => {
|
||||
@ -35,32 +35,4 @@ describe('VarHighlight', () => {
|
||||
expect(container.firstChild).toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
|
||||
// Escaping HTML via helper
|
||||
describe('varHighlightHTML', () => {
|
||||
it('should escape dangerous characters before returning HTML string', () => {
|
||||
// Arrange
|
||||
const props = { name: '<script>alert(\'xss\')</script>' }
|
||||
|
||||
// Act
|
||||
const html = varHighlightHTML(props)
|
||||
|
||||
// Assert
|
||||
expect(html).toContain('<script>alert('xss')</script>')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('should include custom class names in the wrapper element', () => {
|
||||
// Arrange
|
||||
const props = { name: 'data', className: 'text-primary' }
|
||||
|
||||
// Act
|
||||
const html = varHighlightHTML(props)
|
||||
|
||||
// Assert
|
||||
// CSS modules add a hash to class names, so the class attribute may contain _item_xxx
|
||||
expect(html).toContain('text-primary')
|
||||
expect(html).toContain('item')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,23 +24,4 @@ const VarHighlight: FC<IVarHighlightProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DEPRECATED: This function is vulnerable to XSS attacks and should not be used
|
||||
// Use the VarHighlight React component instead
|
||||
export const varHighlightHTML = ({ name, className = '' }: IVarHighlightProps) => {
|
||||
const escapedName = name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const html = `<div class="${s.item} ${className} inline-flex mb-2 items-center justify-center px-1 rounded-md h-5 text-xs font-medium text-primary-600">
|
||||
<span class='opacity-60'>{{</span>
|
||||
<span>${escapedName}</span>
|
||||
<span class='opacity-60'>}}</span>
|
||||
</div>`
|
||||
return html
|
||||
}
|
||||
|
||||
export default React.memo(VarHighlight)
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
isJsonSchemaEmpty,
|
||||
isStringInputType,
|
||||
normalizeSelectDefaultValue,
|
||||
parseCheckboxSelectValue,
|
||||
updatePayloadField,
|
||||
validateConfigModalPayload,
|
||||
} from '../utils'
|
||||
@ -82,9 +81,7 @@ describe('config-modal utils', () => {
|
||||
expect(nextPayload.default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should parse checkbox default values and normalize json schema editor content', () => {
|
||||
expect(parseCheckboxSelectValue('true')).toBe(true)
|
||||
expect(parseCheckboxSelectValue('false')).toBe(false)
|
||||
it('should normalize json schema editor content', () => {
|
||||
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe(JSON.stringify({ type: 'object' }, null, 2))
|
||||
expect(getJsonSchemaEditorValue(InputVarType.textInput, '{"type":"object"}')).toBe('')
|
||||
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe('{"type":"object"}')
|
||||
|
||||
@ -33,10 +33,6 @@ export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boole
|
||||
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
return CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
}
|
||||
|
||||
export const parseCheckboxSelectValue = (value: string) =>
|
||||
value === CHECKBOX_DEFAULT_TRUE_VALUE
|
||||
|
||||
export const normalizeSelectDefaultValue = (inputVar: InputVar) => {
|
||||
if (inputVar.type === InputVarType.select && inputVar.default === '')
|
||||
return { ...inputVar, default: undefined }
|
||||
|
||||
@ -8,7 +8,7 @@ import { ComparisonOperator, LogicalOperator } from '@/app/components/workflow/n
|
||||
import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
|
||||
import { DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app'
|
||||
import { DatasetACLPermission, getDatasetACLCapabilities, hasEditPermissionForDataset } from '@/utils/permission'
|
||||
import { DatasetACLPermission, getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import DatasetConfig from '../index'
|
||||
|
||||
// Mock external dependencies
|
||||
@ -65,7 +65,6 @@ vi.mock('@/utils/permission', () => ({
|
||||
canDelete: false,
|
||||
canAccessConfig: false,
|
||||
})),
|
||||
hasEditPermissionForDataset: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
vi.mock('../../debug/hooks', () => ({
|
||||
@ -477,7 +476,6 @@ describe('DatasetConfig', () => {
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
permission_keys: [DatasetACLPermission.Use],
|
||||
})
|
||||
vi.mocked(hasEditPermissionForDataset).mockReturnValue(true)
|
||||
vi.mocked(getDatasetACLCapabilities).mockReturnValue({
|
||||
canReadonly: false,
|
||||
canEdit: false,
|
||||
@ -994,56 +992,6 @@ describe('DatasetConfig', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Handling', () => {
|
||||
it('should hide edit options when user lacks permission', () => {
|
||||
vi.mocked(hasEditPermissionForDataset).mockReturnValue(false)
|
||||
|
||||
const dataset = createMockDataset({
|
||||
created_by: 'other-user',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
})
|
||||
|
||||
renderDatasetConfig({
|
||||
dataSets: [dataset],
|
||||
})
|
||||
|
||||
// The editable property should be false when no permission
|
||||
// The editable property should be false when no permission
|
||||
expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show readonly state for non-editable datasets', () => {
|
||||
vi.mocked(hasEditPermissionForDataset).mockReturnValue(false)
|
||||
|
||||
const dataset = createMockDataset({
|
||||
created_by: 'admin',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
})
|
||||
|
||||
renderDatasetConfig({
|
||||
dataSets: [dataset],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow editing when user has partial member permission', () => {
|
||||
vi.mocked(hasEditPermissionForDataset).mockReturnValue(true)
|
||||
|
||||
const dataset = createMockDataset({
|
||||
created_by: 'admin',
|
||||
permission: DatasetPermission.partialMembers,
|
||||
partial_member_list: ['user-123'],
|
||||
})
|
||||
|
||||
renderDatasetConfig({
|
||||
dataSets: [dataset],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dataset Reordering and Management', () => {
|
||||
it('should maintain dataset order after updates', () => {
|
||||
const datasets = [
|
||||
|
||||
@ -5,13 +5,11 @@ import {
|
||||
applyAnnotationEdited,
|
||||
applyAnnotationRemoved,
|
||||
buildChatThreadState,
|
||||
buildConversationUrl,
|
||||
getCompletionMessageFiles,
|
||||
getConversationRowValues,
|
||||
getDetailVarList,
|
||||
getFormattedChatList,
|
||||
getThreadChatItems,
|
||||
hasConversationFeedback,
|
||||
isNearTopLoadMore,
|
||||
mergePaginatedChatItems,
|
||||
mergeUniqueChatItems,
|
||||
@ -159,7 +157,6 @@ describe('log list utils', () => {
|
||||
})
|
||||
|
||||
it('should derive urls, scroll thresholds, row values, and detail metadata', () => {
|
||||
expect(buildConversationUrl('/apps/app-1/logs', 'page=2', 'conversation-1')).toBe('/apps/app-1/logs?page=2&conversation_id=conversation-1')
|
||||
expect(isNearTopLoadMore({
|
||||
clientHeight: 200,
|
||||
scrollHeight: 600,
|
||||
@ -212,9 +209,7 @@ describe('log list utils', () => {
|
||||
}, false)).toEqual(['https://example.com/file-1'])
|
||||
})
|
||||
|
||||
it('should remove conversation ids from urls, handle default inputs, and detect conversation feedback', () => {
|
||||
expect(buildConversationUrl('/apps/app-1/logs', 'page=2&conversation_id=conversation-1')).toBe('/apps/app-1/logs?page=2')
|
||||
|
||||
it('should handle default inputs', () => {
|
||||
expect(getConversationRowValues({
|
||||
isChatMode: false,
|
||||
log: {
|
||||
@ -233,8 +228,5 @@ describe('log list utils', () => {
|
||||
leftValue: 'fallback input',
|
||||
rightValue: 0,
|
||||
})
|
||||
|
||||
expect(hasConversationFeedback({ like: 0, dislike: 0 })).toBe(false)
|
||||
expect(hasConversationFeedback({ like: 1, dislike: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -33,11 +33,6 @@ type ConversationLogDetail = {
|
||||
name?: string
|
||||
}
|
||||
|
||||
type ConversationFeedbackStats = {
|
||||
dislike?: number
|
||||
like?: number
|
||||
}
|
||||
|
||||
const getUserInputVariable = (item: UserInputFormItem) => {
|
||||
const variable = Object.values(item)[0]?.variable
|
||||
|
||||
@ -264,18 +259,6 @@ export const applyAnnotationRemoved = (items: IChatItem[], index: number) => ite
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
export const buildConversationUrl = (pathname: string, searchParams: string, conversationId?: string) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
if (conversationId)
|
||||
params.set('conversation_id', conversationId)
|
||||
else
|
||||
params.delete('conversation_id')
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${pathname}?${queryString}` : pathname
|
||||
}
|
||||
|
||||
export const isNearTopLoadMore = ({
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
@ -338,6 +321,3 @@ export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatM
|
||||
|
||||
return messageFiles.flatMap(item => item.url ? [item.url] : [])
|
||||
}
|
||||
|
||||
export const hasConversationFeedback = (stats?: ConversationFeedbackStats | null) =>
|
||||
Boolean(stats?.like || stats?.dislike)
|
||||
|
||||
@ -315,5 +315,4 @@ export const AvgUserInteractions = createBizChartComponent({
|
||||
yMaxWhenEmpty: 500,
|
||||
isAvg: true,
|
||||
})
|
||||
|
||||
export default Chart
|
||||
|
||||
@ -2,14 +2,14 @@ import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AmplitudeProvider from '../AmplitudeProvider'
|
||||
import { resetAmplitudeInitializationForTests } from '../init'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
let AmplitudeProvider: typeof import('../AmplitudeProvider').default
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
@ -32,11 +32,12 @@ vi.mock('@amplitude/plugin-session-replay-browser', () => ({
|
||||
}))
|
||||
|
||||
describe('AmplitudeProvider', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
resetAmplitudeInitializationForTests()
|
||||
;({ default: AmplitudeProvider } = await import('../AmplitudeProvider'))
|
||||
})
|
||||
|
||||
describe('Component', () => {
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ensureAmplitudeInitialized, resetAmplitudeInitializationForTests } from '../init'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
let ensureAmplitudeInitialized: typeof import('../init').ensureAmplitudeInitialized
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
@ -30,11 +31,12 @@ vi.mock('@amplitude/plugin-session-replay-browser', () => ({
|
||||
}))
|
||||
|
||||
describe('amplitude init helper', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
resetAmplitudeInitializationForTests()
|
||||
;({ ensureAmplitudeInitialized } = await import('../init'))
|
||||
})
|
||||
|
||||
describe('ensureAmplitudeInitialized', () => {
|
||||
|
||||
@ -75,8 +75,3 @@ export const ensureAmplitudeInitialized = ({
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Only used by unit tests to reset module-scoped initialization state.
|
||||
export const resetAmplitudeInitializationForTests = () => {
|
||||
isAmplitudeInitialized = false
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Badge, { BadgeState, BadgeVariants } from '../index'
|
||||
import Badge, { BadgeState } from '../index'
|
||||
|
||||
describe('Badge', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -332,29 +332,5 @@ describe('Badge', () => {
|
||||
expect(BadgeState[key as keyof typeof BadgeState]).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BadgeVariants utility', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof BadgeVariants).toBe('function')
|
||||
})
|
||||
|
||||
it('should generate base badge class with default medium size', () => {
|
||||
const result = BadgeVariants({})
|
||||
|
||||
expect(result).toContain('badge')
|
||||
expect(result).toContain('badge-m')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ size: 's' },
|
||||
{ size: 'm' },
|
||||
{ size: 'l' },
|
||||
] as const)('should generate correct classes for size=$size', ({ size }) => {
|
||||
const result = BadgeVariants({ size })
|
||||
|
||||
expect(result).toContain('badge')
|
||||
expect(result).toContain(`badge-${size}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -73,4 +73,4 @@ const Badge: React.FC<BadgeProps> = ({
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export default Badge
|
||||
export { Badge, BadgeState, BadgeVariants }
|
||||
export { Badge, BadgeState }
|
||||
|
||||
@ -1,235 +1,4 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import BlockInput, { getInputKeys } from '../index'
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: vi.fn((_keys: string[]) => ({
|
||||
isValid: true,
|
||||
errorMessageKey: '',
|
||||
errorKey: '',
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('BlockInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(toast, 'error').mockReturnValue('toast-error')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const wrapper = screen.getByTestId('block-input')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with initial value', () => {
|
||||
const { container } = render(<BlockInput value="Hello World" />)
|
||||
expect(container.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('should render variable highlights', () => {
|
||||
render(<BlockInput value="Hello {{name}}" />)
|
||||
const nameElement = screen.getByText('name')
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
expect(nameElement.parentElement).toHaveClass('text-primary-600')
|
||||
})
|
||||
|
||||
it('should render multiple variable highlights', () => {
|
||||
render(<BlockInput value="{{foo}} and {{bar}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display character count in footer when not readonly', () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide footer in readonly mode', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<BlockInput value="test" className="custom-class" />)
|
||||
const innerContent = screen.getByTestId('block-input-content')
|
||||
expect(innerContent).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply readonly prop with max height', () => {
|
||||
render(<BlockInput value="test" readonly />)
|
||||
const contentDiv = screen.getByTestId('block-input').firstChild as Element
|
||||
expect(contentDiv).toHaveClass('max-h-[180px]')
|
||||
})
|
||||
|
||||
it('should have default empty value', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const contentDiv = screen.getByTestId('block-input')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should enter edit mode when clicked', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update value when typing in edit mode', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'Hello World' } })
|
||||
|
||||
expect(textarea).toHaveValue('Hello World')
|
||||
})
|
||||
|
||||
it('should call onConfirm on value change with valid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{name}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on value change with invalid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var');
|
||||
(checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isValid: false,
|
||||
errorMessageKey: 'invalidKey',
|
||||
errorKey: 'test_key',
|
||||
})
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{invalid}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not enter edit mode when readonly is true', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle change when onConfirm is not provided', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'Hello World' } })
|
||||
|
||||
expect(textarea).toHaveValue('Hello World')
|
||||
})
|
||||
|
||||
it('should enter edit mode when clicked with empty value', async () => {
|
||||
render(<BlockInput value="" />)
|
||||
const contentArea = screen.getByTestId('block-input').firstChild as Element
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should exit edit mode on blur-sm', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
|
||||
fireEvent.blur(textarea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const { container } = render(<BlockInput value="" />)
|
||||
expect(container.textContent).toBe('0')
|
||||
const span = screen.getByTestId('block-input').querySelector('span')
|
||||
expect(span).toBeInTheDocument()
|
||||
expect(span).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle value without variables', () => {
|
||||
render(<BlockInput value="plain text" />)
|
||||
expect(screen.getByText('plain text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
const { container } = render(<BlockInput value={`line1\nline2`} />)
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
expect(container.querySelector('br')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple same variables', () => {
|
||||
render(<BlockInput value="{{name}} and {{name}}" />)
|
||||
const highlights = screen.getAllByText('name')
|
||||
expect(highlights).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle value with only variables', () => {
|
||||
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('baz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text adjacent to variables', () => {
|
||||
render(<BlockInput value="prefix {{var}} suffix" />)
|
||||
expect(screen.getByText(/prefix/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/suffix/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
import { getInputKeys } from '../index'
|
||||
|
||||
describe('getInputKeys', () => {
|
||||
it('should extract keys from {{}} syntax', () => {
|
||||
|
||||
@ -1,191 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import BlockInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/BlockInput',
|
||||
component: BlockInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Input value (supports {{variable}} syntax)',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
highLightClassName: {
|
||||
control: 'text',
|
||||
description: 'CSS class for highlighted variables (default: text-blue-500)',
|
||||
},
|
||||
readonly: {
|
||||
control: 'boolean',
|
||||
description: 'Read-only mode',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BlockInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const BlockInputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
const [keys, setKeys] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
<BlockInput
|
||||
{...args}
|
||||
value={value}
|
||||
onConfirm={(newValue, extractedKeys) => {
|
||||
setValue(newValue)
|
||||
setKeys(extractedKeys)
|
||||
console.log('Value confirmed:', newValue)
|
||||
console.log('Extracted keys:', extractedKeys)
|
||||
}}
|
||||
/>
|
||||
{keys.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keys.map(key => (
|
||||
<span key={key} className="rounded-sm bg-blue-500 px-2 py-1 text-xs text-white">
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With single variable
|
||||
export const SingleVariable: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hello {{name}}, welcome to the application!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With multiple variables
|
||||
export const MultipleVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Complex template
|
||||
export const ComplexTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Read-only mode
|
||||
export const ReadOnlyMode: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
|
||||
readonly: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Empty state
|
||||
export const EmptyState: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Long content
|
||||
export const LongContent: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Variables with underscores
|
||||
export const VariablesWithUnderscores: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Adjacent variables
|
||||
export const AdjacentVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Email template
|
||||
export const EmailTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Notification template
|
||||
export const NotificationTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This template uses {{custom_variable}} with custom styling.',
|
||||
readonly: false,
|
||||
className: 'bg-gray-50 border-2 border-blue-200',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Try editing this text and adding variables like {{example}}',
|
||||
readonly: false,
|
||||
className: '',
|
||||
highLightClassName: '',
|
||||
},
|
||||
}
|
||||
@ -1,14 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import VarHighlight from '../../app/configuration/base/var-highlight'
|
||||
|
||||
// regex to match the {{}} and replace it with a span
|
||||
const regex = /\{\{([^}]+)\}\}/g
|
||||
|
||||
@ -28,133 +19,3 @@ export const getInputKeys = (value: string) => {
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
type IBlockInputProps = {
|
||||
value: string
|
||||
className?: string // wrapper class
|
||||
highLightClassName?: string // class for the highlighted text default is text-blue-500
|
||||
readonly?: boolean
|
||||
onConfirm?: (value: string, keys: string[]) => void
|
||||
}
|
||||
|
||||
const BlockInput: FC<IBlockInputProps> = ({
|
||||
value = '',
|
||||
className,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// current is used to store the current value of the contentEditable element
|
||||
const [currentValue, setCurrentValue] = useState<string>(value)
|
||||
useEffect(() => {
|
||||
setCurrentValue(value)
|
||||
}, [value])
|
||||
|
||||
const contentEditableRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
if (isEditing && contentEditableRef.current) {
|
||||
// TODO: Focus at the click position
|
||||
if (currentValue)
|
||||
contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
|
||||
|
||||
contentEditableRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const style = cn({
|
||||
'block size-full border-0 px-4 py-2 text-sm break-all text-gray-900 outline-0': true,
|
||||
'block-input--editing': isEditing,
|
||||
})
|
||||
|
||||
const renderSafeContent = (value: string) => {
|
||||
const parts = value.split(/(\{\{[^}]+\}\}|\n)/g)
|
||||
return parts.map((part, index) => {
|
||||
const variableMatch = /^\{\{([^}]+)\}\}$/.exec(part)
|
||||
if (variableMatch) {
|
||||
return (
|
||||
<VarHighlight
|
||||
key={`var-${index}`}
|
||||
name={variableMatch[1]!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (part === '\n')
|
||||
return <br key={`br-${index}`} />
|
||||
|
||||
return <span key={`text-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Not use useCallback. That will cause out callback get old data.
|
||||
const handleSubmit = (value: string) => {
|
||||
if (onConfirm) {
|
||||
const keys = getInputKeys(value)
|
||||
const result = checkKeys(keys)
|
||||
if (!result.isValid) {
|
||||
toast.error(t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }))
|
||||
return
|
||||
}
|
||||
onConfirm(value, keys)
|
||||
}
|
||||
}
|
||||
|
||||
const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setCurrentValue(value)
|
||||
handleSubmit(value)
|
||||
}, [])
|
||||
|
||||
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
|
||||
const TextAreaContentView = () => {
|
||||
return (
|
||||
<div className={cn(style, className)} data-testid="block-input-content">
|
||||
{renderSafeContent(currentValue || '')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const placeholder = ''
|
||||
const editAreaClassName = 'focus:outline-hidden bg-transparent text-sm'
|
||||
|
||||
const textAreaContent = (
|
||||
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
|
||||
{isEditing
|
||||
? (
|
||||
<div className="h-full px-4 py-2">
|
||||
<textarea
|
||||
ref={contentEditableRef}
|
||||
className={cn(editAreaClassName, 'block size-full resize-none')}
|
||||
placeholder={placeholder}
|
||||
onChange={onValueChange}
|
||||
value={currentValue}
|
||||
onBlur={() => {
|
||||
blur()
|
||||
setIsEditing(false)
|
||||
// click confirm also make blur. Then outer value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: <TextAreaContentView />}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
{!readonly && (
|
||||
<div className="flex pb-2 pl-4">
|
||||
<div className="h-[18px] rounded-md bg-gray-100 px-1 text-xs leading-[18px] text-gray-500">{currentValue?.length}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BlockInput)
|
||||
|
||||
@ -5,7 +5,6 @@ import dayjs, {
|
||||
getDateWithTimezone,
|
||||
getDaysInMonth,
|
||||
getHourIn12Hour,
|
||||
parseDateWithFormat,
|
||||
toDayjs,
|
||||
} from '../dayjs'
|
||||
|
||||
@ -293,46 +292,6 @@ describe('dayjs extended utilities', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for parseDateWithFormat
|
||||
describe('parseDateWithFormat', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(parseDateWithFormat('')).toBeNull()
|
||||
})
|
||||
|
||||
it('should parse with provided format from common formats', () => {
|
||||
// Uses YYYY-MM-DD which is in COMMON_PARSE_FORMATS
|
||||
const result = parseDateWithFormat('2024-06-15', 'YYYY-MM-DD')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should return null for invalid date with format', () => {
|
||||
const result = parseDateWithFormat('not-a-date', 'YYYY-MM-DD')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should try common formats when no format is specified', () => {
|
||||
const result = parseDateWithFormat('2024-06-15')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should parse ISO datetime format', () => {
|
||||
const result = parseDateWithFormat('2024-06-15T12:00:00')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for unparseable string without format', () => {
|
||||
const result = parseDateWithFormat('gibberish')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for formatDateForOutput
|
||||
describe('formatDateForOutput', () => {
|
||||
it('should return empty string for invalid date', () => {
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
getDaysInMonth,
|
||||
getHourIn12Hour,
|
||||
isDayjsObject,
|
||||
parseDateWithFormat,
|
||||
toDayjs,
|
||||
} from '../dayjs'
|
||||
|
||||
@ -256,42 +255,6 @@ describe('toDayjs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── parseDateWithFormat ────────────────────────────────────────────────────
|
||||
describe('parseDateWithFormat', () => {
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseDateWithFormat('')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses with explicit format', () => {
|
||||
// Use YYYY/MM/DD which is unambiguous
|
||||
const result = parseDateWithFormat('2024/05/01', 'YYYY/MM/DD')
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
|
||||
})
|
||||
|
||||
it('returns null for invalid string with explicit format', () => {
|
||||
expect(parseDateWithFormat('not-a-date', 'YYYY-MM-DD')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses using common formats (YYYY-MM-DD)', () => {
|
||||
const result = parseDateWithFormat('2024-05-01')
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
|
||||
})
|
||||
|
||||
it('parses using common formats (YYYY/MM/DD)', () => {
|
||||
const result = parseDateWithFormat('2024/05/01')
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
|
||||
})
|
||||
|
||||
it('parses ISO datetime strings via common formats', () => {
|
||||
const result = parseDateWithFormat('2024-05-01T14:30:00')
|
||||
expect(result?.hour()).toBe(14)
|
||||
})
|
||||
|
||||
it('returns null for completely unparseable string', () => {
|
||||
expect(parseDateWithFormat('ZZZZ-ZZ-ZZ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── formatDateForOutput ────────────────────────────────────────────────────
|
||||
describe('formatDateForOutput', () => {
|
||||
it('returns empty string for invalid date', () => {
|
||||
|
||||
@ -222,32 +222,6 @@ export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptio
|
||||
warnParseFailure(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Parse date with multiple format support
|
||||
export const parseDateWithFormat = (dateString: string, format?: string): Dayjs | null => {
|
||||
if (!dateString)
|
||||
return null
|
||||
|
||||
// If format is specified, use it directly
|
||||
if (format) {
|
||||
const parsed = dayjs(dateString, format, true)
|
||||
return parsed.isValid() ? parsed : null
|
||||
}
|
||||
|
||||
// Try common date formats
|
||||
const formats = [
|
||||
...COMMON_PARSE_FORMATS,
|
||||
]
|
||||
|
||||
for (const fmt of formats) {
|
||||
const parsed = dayjs(dateString, fmt, true)
|
||||
if (parsed.isValid())
|
||||
return parsed
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Format date output with localization support
|
||||
export const formatDateForOutput = (date: Dayjs, includeTime: boolean = false, _locale: string = 'en-US'): string => {
|
||||
if (!date || !date.isValid())
|
||||
|
||||
@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from '../index'
|
||||
import ErrorBoundary, { withErrorBoundary } from '../index'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isDev: false,
|
||||
@ -340,54 +340,6 @@ describe('ErrorBoundary utility exports', () => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Validate imperative error hook behavior.
|
||||
describe('useErrorHandler', () => {
|
||||
it('should trigger error boundary fallback when setError is called', async () => {
|
||||
const HookConsumer = () => {
|
||||
const setError = useErrorHandler()
|
||||
return (
|
||||
<button onClick={() => setError(new Error('handler boom'))}>
|
||||
Trigger hook error
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Hook fallback shown</div>}>
|
||||
<HookConsumer />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
|
||||
|
||||
expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate async error bridge hook behavior.
|
||||
describe('useAsyncError', () => {
|
||||
it('should trigger error boundary fallback when async error callback is called', async () => {
|
||||
const AsyncHookConsumer = () => {
|
||||
const throwAsyncError = useAsyncError()
|
||||
return (
|
||||
<button onClick={() => throwAsyncError(new Error('async hook boom'))}>
|
||||
Trigger async hook error
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Async fallback shown</div>}>
|
||||
<AsyncHookConsumer />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
|
||||
|
||||
expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate HOC wrapper behavior and metadata.
|
||||
describe('withErrorBoundary', () => {
|
||||
it('should wrap component and render custom title when wrapped component throws', async () => {
|
||||
@ -427,25 +379,4 @@ describe('ErrorBoundary utility exports', () => {
|
||||
expect(Wrapped.displayName).toBe('withErrorBoundary(Component)')
|
||||
})
|
||||
})
|
||||
|
||||
// Validate simple fallback helper component.
|
||||
describe('ErrorFallback', () => {
|
||||
it('should render message and call reset action when button is clicked', () => {
|
||||
const resetErrorBoundaryAction = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorFallback
|
||||
error={new Error('fallback helper message')}
|
||||
resetErrorBoundaryAction={resetErrorBoundaryAction}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
|
||||
expect(screen.getByText('fallback helper message')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -238,35 +238,7 @@ const ErrorBoundary: React.FC<ErrorBoundaryProps> = (props) => {
|
||||
onResetKeysChange={onResetKeysChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Hook for imperative error handling
|
||||
export function useErrorHandler() {
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (error)
|
||||
throw error
|
||||
}, [error])
|
||||
|
||||
return setError
|
||||
}
|
||||
|
||||
// Hook for catching async errors
|
||||
export function useAsyncError() {
|
||||
const [, setError] = useState()
|
||||
|
||||
return useCallback(
|
||||
(error: Error) => {
|
||||
setError(() => {
|
||||
throw error
|
||||
})
|
||||
},
|
||||
[setError],
|
||||
)
|
||||
}
|
||||
|
||||
// HOC for wrapping components with error boundary
|
||||
}// HOC for wrapping components with error boundary
|
||||
export function withErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>,
|
||||
@ -281,23 +253,4 @@ export function withErrorBoundary<P extends object>(
|
||||
|
||||
return WrappedComponent
|
||||
}
|
||||
|
||||
// Simple error fallback component
|
||||
export const ErrorFallback: React.FC<{
|
||||
error: Error
|
||||
resetErrorBoundaryAction: () => void
|
||||
}> = ({ error, resetErrorBoundaryAction }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-8">
|
||||
<h2 className="mb-2 text-lg font-semibold text-red-800">{t('errorBoundary.fallbackTitle', { ns: 'common' })}</h2>
|
||||
<p className="mb-4 text-center text-red-600">{error.message}</p>
|
||||
<Button onClick={resetErrorBoundaryAction} size="small">
|
||||
{t('errorBoundary.tryAgainCompact', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
fileUpload,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
getFileNameFromUrl,
|
||||
getFilesInLogs,
|
||||
getFileUploadErrorMessage,
|
||||
getProcessedFiles,
|
||||
@ -627,18 +626,6 @@ describe('file-uploader utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileNameFromUrl', () => {
|
||||
it('should extract filename from URL', () => {
|
||||
expect(getFileNameFromUrl('http://example.com/path/file.txt'))
|
||||
.toBe('file.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for URL ending with slash', () => {
|
||||
expect(getFileNameFromUrl('http://example.com/path/'))
|
||||
.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportFileExtensionList', () => {
|
||||
it('should handle custom file types', () => {
|
||||
const result = getSupportFileExtensionList(
|
||||
|
||||
@ -206,12 +206,6 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getFileNameFromUrl = (url: string) => {
|
||||
const urlParts = url.split('/')
|
||||
return urlParts[urlParts.length - 1] || ''
|
||||
}
|
||||
|
||||
export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => {
|
||||
if (allowFileTypes.includes(SupportUploadFileTypes.custom))
|
||||
return allowFileExtensions.map(item => item.slice(1).toUpperCase())
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImageGallery, { ImageGalleryTest } from '..'
|
||||
import ImageGallery from '..'
|
||||
|
||||
const getImages = (container: HTMLElement) => container.querySelectorAll('img')
|
||||
|
||||
@ -132,13 +132,3 @@ describe('ImageGallery', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageGalleryTest', () => {
|
||||
it('should render multiple ImageGallery instances', () => {
|
||||
const { container } = render(<ImageGalleryTest />)
|
||||
|
||||
const imgs = getImages(container)
|
||||
// 6 images renders galleries with 1+2+3+4+5+6 = 21 images total
|
||||
expect(imgs.length).toBe(21)
|
||||
})
|
||||
})
|
||||
|
||||
@ -67,24 +67,3 @@ const ImageGallery: FC<Props> = ({
|
||||
}
|
||||
|
||||
export default React.memo(ImageGallery)
|
||||
|
||||
export const ImageGalleryTest = () => {
|
||||
const imgGallerySrcs = (() => {
|
||||
const srcs = []
|
||||
for (let i = 0; i < 6; i++)
|
||||
// srcs.push('https://placekitten.com/640/360')
|
||||
// srcs.push('https://placekitten.com/360/640')
|
||||
srcs.push('https://placekitten.com/360/360')
|
||||
|
||||
return srcs
|
||||
})()
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{imgGallerySrcs.map((_, index) => (
|
||||
<div key={index} className="rounded-lg bg-[#D1E9FF80] p-4 pb-2">
|
||||
<ImageGallery srcs={imgGallerySrcs.slice(0, index + 1)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { ClipboardEvent, DragEvent } from 'react'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { useClipboardUploader, useDraggableUploader, useImageFiles, useLocalFileUploader } from '../hooks'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useImageFiles, useLocalFileUploader } from '../hooks'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@ -35,15 +34,6 @@ const createImageFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createVisionSettings = (overrides: Partial<VisionSettings> = {}): VisionSettings => ({
|
||||
enabled: true,
|
||||
number_limits: 5,
|
||||
detail: Resolution.high,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
image_file_size_limit: 10,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useImageFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -493,284 +483,3 @@ describe('useLocalFileUploader', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useClipboardUploader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should be disabled when visionConfig is undefined', () => {
|
||||
const onUpload = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useClipboardUploader({ files: [], onUpload }),
|
||||
)
|
||||
|
||||
// The hook returns onPaste, and since disabled is true, pasting should not upload
|
||||
expect(result.current.onPaste).toBeInstanceOf(Function)
|
||||
})
|
||||
|
||||
it('should be disabled when visionConfig.enabled is false', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings({ enabled: false })
|
||||
const { result } = renderHook(() =>
|
||||
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' })
|
||||
const mockEvent = {
|
||||
clipboardData: { files: [file] },
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent<HTMLTextAreaElement>
|
||||
act(() => {
|
||||
result.current.onPaste(mockEvent)
|
||||
})
|
||||
|
||||
// Paste occurs but the file should NOT be uploaded because disabled
|
||||
expect(onUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled when local upload is not allowed', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings({
|
||||
transfer_methods: [TransferMethod.remote_url],
|
||||
})
|
||||
renderHook(() =>
|
||||
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
expect(onUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled when files count reaches number_limits', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings({ number_limits: 1 })
|
||||
const files = [createImageFile({ _id: 'file-1' })]
|
||||
|
||||
renderHook(() =>
|
||||
useClipboardUploader({ files, visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
expect(onUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleLocalFileUpload when pasting a file', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' })
|
||||
const mockEvent = {
|
||||
clipboardData: {
|
||||
files: [file],
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent<HTMLTextAreaElement>
|
||||
|
||||
act(() => {
|
||||
result.current.onPaste(mockEvent)
|
||||
})
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prevent default when pasting text (no file)', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const mockEvent = {
|
||||
clipboardData: {
|
||||
files: [] as File[],
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent<HTMLTextAreaElement>
|
||||
|
||||
act(() => {
|
||||
result.current.onPaste(mockEvent)
|
||||
})
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDraggableUploader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createDragEvent = (files: File[] = []) => ({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
files,
|
||||
},
|
||||
} as unknown as DragEvent<HTMLDivElement>)
|
||||
|
||||
it('should return drag event handlers and isDragActive state', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
expect(result.current.onDragEnter).toBeInstanceOf(Function)
|
||||
expect(result.current.onDragOver).toBeInstanceOf(Function)
|
||||
expect(result.current.onDragLeave).toBeInstanceOf(Function)
|
||||
expect(result.current.onDrop).toBeInstanceOf(Function)
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should set isDragActive to true on dragEnter when not disabled', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnter(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not set isDragActive on dragEnter when disabled', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings({ enabled: false })
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnter(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should call preventDefault and stopPropagation on dragOver', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.onDragOver(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isDragActive to false on dragLeave', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
// First activate drag
|
||||
act(() => {
|
||||
result.current.onDragEnter(createDragEvent())
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
|
||||
// Then leave
|
||||
const leaveEvent = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.onDragLeave(leaveEvent)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
expect(leaveEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(leaveEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isDragActive to false on drop and upload file', async () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' })
|
||||
const event = createDragEvent([file])
|
||||
|
||||
// Activate drag first
|
||||
act(() => {
|
||||
result.current.onDragEnter(createDragEvent())
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onDrop(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
// Verify the file was actually handed to the upload pipeline
|
||||
await vi.waitFor(() => {
|
||||
expect(mockImageUpload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not upload when dropping with no files', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings()
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
files: [] as unknown as FileList,
|
||||
},
|
||||
} as unknown as React.DragEvent<HTMLDivElement>
|
||||
|
||||
act(() => {
|
||||
result.current.onDrop(event)
|
||||
})
|
||||
|
||||
// onUpload should not be called directly since no file was dropped
|
||||
expect(onUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled when files count exceeds number_limits', () => {
|
||||
const onUpload = vi.fn()
|
||||
const settings = createVisionSettings({ number_limits: 1 })
|
||||
const files = [createImageFile({ _id: 'file-1' })]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraggableUploader<HTMLDivElement>({ files, visionConfig: settings, onUpload }),
|
||||
)
|
||||
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.onDragEnter(event)
|
||||
})
|
||||
|
||||
// Should not activate drag when disabled
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ClipboardEvent } from 'react'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -154,75 +153,3 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
|
||||
}, [disabled, limit, t, onUpload, params?.token])
|
||||
return { disabled, handleLocalFileUpload }
|
||||
}
|
||||
type useClipboardUploaderProps = {
|
||||
files: ImageFile[]
|
||||
visionConfig?: VisionSettings
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
}
|
||||
export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => {
|
||||
const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
|
||||
const disabled = useMemo(() => !visionConfig
|
||||
|| !visionConfig?.enabled
|
||||
|| !allowLocalUpload
|
||||
|| files.length >= visionConfig.number_limits!, [allowLocalUpload, files.length, visionConfig])
|
||||
const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled })
|
||||
const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// reserve native text copy behavior
|
||||
const file = e.clipboardData?.files[0]
|
||||
// when copied file, prevent default action
|
||||
if (file) {
|
||||
e.preventDefault()
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}, [handleLocalFileUpload])
|
||||
return {
|
||||
onPaste: handleClipboardPaste,
|
||||
}
|
||||
}
|
||||
type useDraggableUploaderProps = {
|
||||
files: ImageFile[]
|
||||
visionConfig?: VisionSettings
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
}
|
||||
export const useDraggableUploader = <T extends HTMLElement>({ visionConfig, onUpload, files }: useDraggableUploaderProps) => {
|
||||
const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
|
||||
const disabled = useMemo(() => !visionConfig
|
||||
|| !visionConfig?.enabled
|
||||
|| !allowLocalUpload
|
||||
|| files.length >= visionConfig.number_limits!, [allowLocalUpload, files.length, visionConfig])
|
||||
const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({ disabled, onUpload, limit })
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled)
|
||||
setIsDragActive(true)
|
||||
}, [disabled])
|
||||
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}, [])
|
||||
const handleDrop = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (!file)
|
||||
return
|
||||
handleLocalFileUpload(file)
|
||||
}, [handleLocalFileUpload])
|
||||
return {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
isDragActive,
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import {
|
||||
checkHasContextBlock,
|
||||
checkHasHistoryBlock,
|
||||
checkHasQueryBlock,
|
||||
checkHasRequestURLBlock,
|
||||
CONTEXT_PLACEHOLDER_TEXT,
|
||||
CURRENT_PLACEHOLDER_TEXT,
|
||||
ERROR_MESSAGE_PLACEHOLDER_TEXT,
|
||||
@ -55,12 +54,6 @@ describe('prompt-editor constants', () => {
|
||||
expect(checkHasQueryBlock('plain text')).toBe(false)
|
||||
expect(checkHasQueryBlock(`before ${QUERY_PLACEHOLDER_TEXT} after`)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect request url placeholder only when present', () => {
|
||||
expect(checkHasRequestURLBlock('')).toBe(false)
|
||||
expect(checkHasRequestURLBlock('plain text')).toBe(false)
|
||||
expect(checkHasRequestURLBlock(`before ${REQUEST_URL_PLACEHOLDER_TEXT} after`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputVars', () => {
|
||||
|
||||
@ -2,7 +2,6 @@ import type {
|
||||
Klass,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
RangeSelection,
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import type { CustomTextNode } from '../plugins/custom-text/node'
|
||||
@ -10,21 +9,15 @@ import type { MenuTextMatch } from '../types'
|
||||
import {
|
||||
$splitNodeContainingQuery,
|
||||
decoratorTransform,
|
||||
getSelectedNode,
|
||||
registerLexicalTextEntity,
|
||||
textToEditorState,
|
||||
} from '../utils'
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
isAtNodeEnd: false,
|
||||
selection: null as unknown,
|
||||
createTextNode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/selection', () => ({
|
||||
$isAtNodeEnd: () => mockState.isAtNodeEnd,
|
||||
}))
|
||||
|
||||
vi.mock('lexical', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('lexical')>()
|
||||
return {
|
||||
@ -43,7 +36,6 @@ vi.mock('./plugins/custom-text/node', () => ({
|
||||
describe('prompt-editor/utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockState.isAtNodeEnd = false
|
||||
mockState.selection = null
|
||||
})
|
||||
function makeEditor() {
|
||||
@ -57,74 +49,6 @@ describe('prompt-editor/utils', () => {
|
||||
return { editor, registerNodeTransform }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getSelectedNode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getSelectedNode', () => {
|
||||
it('should return anchor node when anchor and focus are the same node', () => {
|
||||
const sharedNode = { id: 'same' }
|
||||
const selection = {
|
||||
anchor: { getNode: () => sharedNode },
|
||||
focus: { getNode: () => sharedNode },
|
||||
isBackward: () => false,
|
||||
} as unknown as RangeSelection
|
||||
|
||||
expect(getSelectedNode(selection)).toBe(sharedNode)
|
||||
})
|
||||
|
||||
it('should return anchor node for backward selection when focus IS at node end', () => {
|
||||
const anchorNode = { id: 'anchor' }
|
||||
const focusNode = { id: 'focus' }
|
||||
const selection = {
|
||||
anchor: { getNode: () => anchorNode },
|
||||
focus: { getNode: () => focusNode },
|
||||
isBackward: () => true,
|
||||
} as unknown as RangeSelection
|
||||
|
||||
mockState.isAtNodeEnd = true
|
||||
expect(getSelectedNode(selection)).toBe(anchorNode)
|
||||
})
|
||||
|
||||
it('should return focus node for backward selection when focus is NOT at node end', () => {
|
||||
const anchorNode = { id: 'anchor' }
|
||||
const focusNode = { id: 'focus' }
|
||||
const selection = {
|
||||
anchor: { getNode: () => anchorNode },
|
||||
focus: { getNode: () => focusNode },
|
||||
isBackward: () => true,
|
||||
} as unknown as RangeSelection
|
||||
|
||||
mockState.isAtNodeEnd = false
|
||||
expect(getSelectedNode(selection)).toBe(focusNode)
|
||||
})
|
||||
|
||||
it('should return anchor node for forward selection when anchor IS at node end', () => {
|
||||
const anchorNode = { id: 'anchor' }
|
||||
const focusNode = { id: 'focus' }
|
||||
const selection = {
|
||||
anchor: { getNode: () => anchorNode },
|
||||
focus: { getNode: () => focusNode },
|
||||
isBackward: () => false,
|
||||
} as unknown as RangeSelection
|
||||
|
||||
mockState.isAtNodeEnd = true
|
||||
expect(getSelectedNode(selection)).toBe(anchorNode)
|
||||
})
|
||||
|
||||
it('should return focus node for forward selection when anchor is NOT at node end', () => {
|
||||
const anchorNode = { id: 'anchor' }
|
||||
const focusNode = { id: 'focus' }
|
||||
const selection = {
|
||||
anchor: { getNode: () => anchorNode },
|
||||
focus: { getNode: () => focusNode },
|
||||
isBackward: () => false,
|
||||
} as unknown as RangeSelection
|
||||
|
||||
mockState.isAtNodeEnd = false
|
||||
expect(getSelectedNode(selection)).toBe(focusNode)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registerLexicalTextEntity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -30,13 +30,6 @@ export const checkHasQueryBlock = (text: string) => {
|
||||
return false
|
||||
return text.includes(QUERY_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
export const checkHasRequestURLBlock = (text: string) => {
|
||||
if (!text)
|
||||
return false
|
||||
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
/*
|
||||
* {{#1711617514996.name#}} => [1711617514996, name]
|
||||
* {{#1711617514996.sys.query#}} => [sys, query]
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
import CurrentBlockComponent from '../component'
|
||||
import {
|
||||
$createCurrentBlockNode,
|
||||
$isCurrentBlockNode,
|
||||
CurrentBlockNode,
|
||||
} from '../node'
|
||||
|
||||
@ -175,21 +174,5 @@ describe('CurrentBlockNode', () => {
|
||||
|
||||
expect(node).toBeInstanceOf(CurrentBlockNode)
|
||||
})
|
||||
|
||||
it('should identify current block nodes using type guard helper', () => {
|
||||
const editor = createTestEditor()
|
||||
let node!: CurrentBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createCurrentBlockNode(GeneratorType.prompt)
|
||||
appendNodeToRoot(node)
|
||||
})
|
||||
})
|
||||
|
||||
expect($isCurrentBlockNode(node)).toBe(true)
|
||||
expect($isCurrentBlockNode(null)).toBe(false)
|
||||
expect($isCurrentBlockNode(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import CurrentBlockComponent from './component'
|
||||
@ -70,9 +70,3 @@ export class CurrentBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
export function $createCurrentBlockNode(type: GeneratorType): CurrentBlockNode {
|
||||
return new CurrentBlockNode(type)
|
||||
}
|
||||
|
||||
export function $isCurrentBlockNode(
|
||||
node: CurrentBlockNode | LexicalNode | null | undefined,
|
||||
): boolean {
|
||||
return node instanceof CurrentBlockNode
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
|
||||
import { createEditor } from 'lexical'
|
||||
import { $createErrorMessageBlockNode, $isErrorMessageBlockNode, ErrorMessageBlockNode } from '../node'
|
||||
import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from '../node'
|
||||
|
||||
describe('ErrorMessageBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
@ -72,15 +72,4 @@ describe('ErrorMessageBlockNode', () => {
|
||||
expect(imported).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct type guard values for lexical and non lexical inputs', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
|
||||
expect($isErrorMessageBlockNode(node)).toBe(true)
|
||||
expect($isErrorMessageBlockNode(null)).toBe(false)
|
||||
expect($isErrorMessageBlockNode(undefined)).toBe(false)
|
||||
expect($isErrorMessageBlockNode({} as ErrorMessageBlockNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import ErrorMessageBlockComponent from './component'
|
||||
|
||||
@ -59,9 +59,3 @@ export class ErrorMessageBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
export function $createErrorMessageBlockNode(): ErrorMessageBlockNode {
|
||||
return new ErrorMessageBlockNode()
|
||||
}
|
||||
|
||||
export function $isErrorMessageBlockNode(
|
||||
node: ErrorMessageBlockNode | LexicalNode | null | undefined,
|
||||
): boolean {
|
||||
return node instanceof ErrorMessageBlockNode
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
import HITLInputBlockComponent from '../component'
|
||||
import {
|
||||
$createHITLInputNode,
|
||||
$isHITLInputNode,
|
||||
HITLInputNode,
|
||||
} from '../node'
|
||||
|
||||
@ -202,7 +201,7 @@ describe('HITLInputNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should create and update DOM and support helper type guard', () => {
|
||||
it('should create and update DOM', () => {
|
||||
const editor = createTestEditor()
|
||||
const props = createNodeProps()
|
||||
|
||||
@ -227,11 +226,7 @@ describe('HITLInputNode', () => {
|
||||
|
||||
expectInlineWrapperDom(dom, ['w-[calc(100%-1px)]', 'support-drag'])
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
expect($isHITLInputNode(node)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
expect($isHITLInputNode(null)).toBe(false)
|
||||
expect($isHITLInputNode(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { GetVarType } from '../../types'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
@ -269,9 +269,3 @@ export function $createHITLInputNode(
|
||||
readonly,
|
||||
)
|
||||
}
|
||||
|
||||
export function $isHITLInputNode(
|
||||
node: HITLInputNode | LexicalNode | null | undefined,
|
||||
): node is HITLInputNode {
|
||||
return node instanceof HITLInputNode
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
import LastRunBlockComponent from '../component'
|
||||
import {
|
||||
$createLastRunBlockNode,
|
||||
$isLastRunBlockNode,
|
||||
LastRunBlockNode,
|
||||
} from '../node'
|
||||
|
||||
@ -102,13 +101,5 @@ describe('LastRunBlockNode', () => {
|
||||
|
||||
expect(node).toBeInstanceOf(LastRunBlockNode)
|
||||
})
|
||||
|
||||
it('should identify last run block nodes using type guard helper', () => {
|
||||
const { node } = createNodeInEditor()
|
||||
|
||||
expect($isLastRunBlockNode(node)).toBe(true)
|
||||
expect($isLastRunBlockNode(null)).toBe(false)
|
||||
expect($isLastRunBlockNode(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import LastRunBlockComponent from './component'
|
||||
|
||||
@ -59,9 +59,3 @@ export class LastRunBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
export function $createLastRunBlockNode(): LastRunBlockNode {
|
||||
return new LastRunBlockNode()
|
||||
}
|
||||
|
||||
export function $isLastRunBlockNode(
|
||||
node: LastRunBlockNode | LexicalNode | null | undefined,
|
||||
): boolean {
|
||||
return node instanceof LastRunBlockNode
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
import RequestURLBlockComponent from '../component'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
$isRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from '../node'
|
||||
|
||||
@ -102,13 +101,5 @@ describe('RequestURLBlockNode', () => {
|
||||
|
||||
expect(node).toBeInstanceOf(RequestURLBlockNode)
|
||||
})
|
||||
|
||||
it('should identify request URL block nodes using type guard', () => {
|
||||
const { node } = createNodeInEditor()
|
||||
|
||||
expect($isRequestURLBlockNode(node)).toBe(true)
|
||||
expect($isRequestURLBlockNode(null)).toBe(false)
|
||||
expect($isRequestURLBlockNode(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
|
||||
import type { SerializedLexicalNode } from 'lexical'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import RequestURLBlockComponent from './component'
|
||||
|
||||
@ -51,9 +51,3 @@ export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
export function $createRequestURLBlockNode(): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode()
|
||||
}
|
||||
|
||||
export function $isRequestURLBlockNode(
|
||||
node: RequestURLBlockNode | LexicalNode | null | undefined,
|
||||
): node is RequestURLBlockNode {
|
||||
return node instanceof RequestURLBlockNode
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import { createEditor } from 'lexical'
|
||||
import RosterReferenceBlockComponent from '../component'
|
||||
import {
|
||||
$createRosterReferenceBlockNode,
|
||||
$isRosterReferenceBlockNode,
|
||||
RosterReferenceBlockNode,
|
||||
} from '../node'
|
||||
import {
|
||||
@ -104,16 +103,12 @@ describe('RosterReferenceBlockNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node with helper and support type guard checks', () => {
|
||||
it('should create node with helper', () => {
|
||||
runInEditor(() => {
|
||||
const node = $createRosterReferenceBlockNode('[§skill:playwright:Playwright§]')
|
||||
|
||||
expect(node).toBeInstanceOf(RosterReferenceBlockNode)
|
||||
expect(node.getTextContent()).toBe('[§skill:playwright:Playwright§]')
|
||||
expect($isRosterReferenceBlockNode(node)).toBe(true)
|
||||
expect($isRosterReferenceBlockNode(null)).toBe(false)
|
||||
expect($isRosterReferenceBlockNode(undefined)).toBe(false)
|
||||
expect($isRosterReferenceBlockNode({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type {
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
} from 'lexical'
|
||||
@ -68,9 +67,3 @@ export class RosterReferenceBlockNode extends DecoratorNode<JSX.Element> {
|
||||
export function $createRosterReferenceBlockNode(text = ''): RosterReferenceBlockNode {
|
||||
return $applyNodeReplacement(new RosterReferenceBlockNode(text))
|
||||
}
|
||||
|
||||
export function $isRosterReferenceBlockNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is RosterReferenceBlockNode {
|
||||
return node instanceof RosterReferenceBlockNode
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNod
|
||||
import { createEditor } from 'lexical'
|
||||
import {
|
||||
$createVariableValueBlockNode,
|
||||
$isVariableValueNodeBlock,
|
||||
VariableValueBlockNode,
|
||||
} from '../node'
|
||||
|
||||
@ -77,16 +76,12 @@ describe('VariableValueBlockNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node with helper and support type guard checks', () => {
|
||||
it('should create node with helper', () => {
|
||||
runInEditor(() => {
|
||||
const node = $createVariableValueBlockNode('{{org_id}}')
|
||||
|
||||
expect(node).toBeInstanceOf(VariableValueBlockNode)
|
||||
expect(node.getTextContent()).toBe('{{org_id}}')
|
||||
expect($isVariableValueNodeBlock(node)).toBe(true)
|
||||
expect($isVariableValueNodeBlock(null)).toBe(false)
|
||||
expect($isVariableValueNodeBlock(undefined)).toBe(false)
|
||||
expect($isVariableValueNodeBlock({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type {
|
||||
EditorConfig,
|
||||
LexicalNode,
|
||||
SerializedTextNode,
|
||||
} from 'lexical'
|
||||
import {
|
||||
@ -56,9 +55,3 @@ export class VariableValueBlockNode extends TextNode {
|
||||
export function $createVariableValueBlockNode(text = ''): VariableValueBlockNode {
|
||||
return $applyNodeReplacement(new VariableValueBlockNode(text))
|
||||
}
|
||||
|
||||
export function $isVariableValueNodeBlock(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is VariableValueBlockNode {
|
||||
return node instanceof VariableValueBlockNode
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
$createWorkflowVariableBlockNode,
|
||||
$isWorkflowVariableBlockNode,
|
||||
WorkflowVariableBlockNode,
|
||||
} from '../node'
|
||||
|
||||
@ -145,7 +144,7 @@ describe('WorkflowVariableBlockNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node helper and type guard checks', () => {
|
||||
it('should create node helper', () => {
|
||||
runInEditor(() => {
|
||||
const availableVariables: NodeOutPutVar[] = [{
|
||||
nodeId: 'node-1',
|
||||
@ -156,10 +155,6 @@ describe('WorkflowVariableBlockNode', () => {
|
||||
|
||||
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect(node.getAvailableVariables()).toEqual(availableVariables)
|
||||
expect($isWorkflowVariableBlockNode(node)).toBe(true)
|
||||
expect($isWorkflowVariableBlockNode(null)).toBe(false)
|
||||
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)
|
||||
expect($isWorkflowVariableBlockNode({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { GetVarType, WorkflowVariableBlockType } from '../../types'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
@ -137,9 +137,3 @@ export function $createWorkflowVariableBlockNode(
|
||||
availableVariables,
|
||||
)
|
||||
}
|
||||
|
||||
export function $isWorkflowVariableBlockNode(
|
||||
node: WorkflowVariableBlockNode | LexicalNode | null | undefined,
|
||||
): node is WorkflowVariableBlockNode {
|
||||
return node instanceof WorkflowVariableBlockNode
|
||||
}
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type {
|
||||
ElementNode,
|
||||
Klass,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
RangeSelection,
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import type { MenuTextMatch } from './types'
|
||||
import { $isAtNodeEnd } from '@lexical/selection'
|
||||
import {
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
@ -17,23 +14,6 @@ import {
|
||||
} from 'lexical'
|
||||
import { CustomTextNode } from './plugins/custom-text/node'
|
||||
|
||||
export function getSelectedNode(
|
||||
selection: RangeSelection,
|
||||
): TextNode | ElementNode {
|
||||
const anchor = selection.anchor
|
||||
const focus = selection.focus
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const focusNode = selection.focus.getNode()
|
||||
if (anchorNode === focusNode)
|
||||
return anchorNode
|
||||
|
||||
const isBackward = selection.isBackward()
|
||||
if (isBackward)
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode
|
||||
else
|
||||
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
|
||||
}
|
||||
|
||||
export function registerLexicalTextEntity<T extends TextNode>(
|
||||
editor: LexicalEditor,
|
||||
getMatch: (text: string) => null | EntityMatch,
|
||||
|
||||
@ -64,64 +64,6 @@ describe('zendesk/utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setZendeskWidgetVisibility', () => {
|
||||
it('should call window.zE to show widget when visible is true', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
|
||||
const { setZendeskWidgetVisibility } = await import('../utils')
|
||||
|
||||
setZendeskWidgetVisibility(true)
|
||||
|
||||
expect(window.zE).toHaveBeenCalledWith('messenger', 'show')
|
||||
})
|
||||
|
||||
it('should call window.zE to hide widget when visible is false', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
|
||||
const { setZendeskWidgetVisibility } = await import('../utils')
|
||||
|
||||
setZendeskWidgetVisibility(false)
|
||||
|
||||
expect(window.zE).toHaveBeenCalledWith('messenger', 'hide')
|
||||
})
|
||||
|
||||
it('should not call window.zE when IS_CE_EDITION is true', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: true }))
|
||||
const { setZendeskWidgetVisibility } = await import('../utils')
|
||||
|
||||
setZendeskWidgetVisibility(true)
|
||||
|
||||
expect(window.zE).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleZendeskWindow', () => {
|
||||
it('should call window.zE to open messenger when open is true', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
|
||||
const { toggleZendeskWindow } = await import('../utils')
|
||||
|
||||
toggleZendeskWindow(true)
|
||||
|
||||
expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
|
||||
})
|
||||
|
||||
it('should call window.zE to close messenger when open is false', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
|
||||
const { toggleZendeskWindow } = await import('../utils')
|
||||
|
||||
toggleZendeskWindow(false)
|
||||
|
||||
expect(window.zE).toHaveBeenCalledWith('messenger', 'close')
|
||||
})
|
||||
|
||||
it('should not call window.zE when IS_CE_EDITION is true', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: true }))
|
||||
const { toggleZendeskWindow } = await import('../utils')
|
||||
|
||||
toggleZendeskWindow(true)
|
||||
|
||||
expect(window.zE).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openZendeskWindow', () => {
|
||||
it('should show and open messenger when zE exists', async () => {
|
||||
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
|
||||
|
||||
@ -22,16 +22,6 @@ export const setZendeskConversationFields = (fields: ConversationField[], callba
|
||||
window.zE('messenger:set', 'conversationFields', fields, callback)
|
||||
}
|
||||
|
||||
export const setZendeskWidgetVisibility = (visible: boolean) => {
|
||||
if (!IS_CE_EDITION && window.zE)
|
||||
window.zE('messenger', visible ? 'show' : 'hide')
|
||||
}
|
||||
|
||||
export const toggleZendeskWindow = (open: boolean) => {
|
||||
if (!IS_CE_EDITION && window.zE)
|
||||
window.zE('messenger', open ? 'open' : 'close')
|
||||
}
|
||||
|
||||
type OpenZendeskWindowOptions = {
|
||||
interval?: number
|
||||
retries?: number
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config'
|
||||
import { ALL_PLANS, contactSalesUrl, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE } from '../config'
|
||||
import { Priority } from '../type'
|
||||
|
||||
describe('Billing Config', () => {
|
||||
@ -7,14 +7,6 @@ describe('Billing Config', () => {
|
||||
expect(NUM_INFINITE).toBe(-1)
|
||||
})
|
||||
|
||||
it('should define contractSales string', () => {
|
||||
expect(contractSales).toBe('contractSales')
|
||||
})
|
||||
|
||||
it('should define unAvailable string', () => {
|
||||
expect(unAvailable).toBe('unAvailable')
|
||||
})
|
||||
|
||||
it('should define valid URL constants', () => {
|
||||
expect(contactSalesUrl).toMatch(/^https:\/\//)
|
||||
expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//)
|
||||
|
||||
@ -4,9 +4,6 @@ import { Plan, Priority } from '@/app/components/billing/type'
|
||||
const supportModelProviders = 'OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicate'
|
||||
|
||||
export const NUM_INFINITE = -1
|
||||
export const contractSales = 'contractSales'
|
||||
export const unAvailable = 'unAvailable'
|
||||
|
||||
export const contactSalesUrl = 'https://vikgc6bnu1s.typeform.com/dify-business'
|
||||
export const getStartedWithCommunityUrl = 'https://github.com/langgenius/dify'
|
||||
export const getWithPremiumUrl = 'https://aws.amazon.com/marketplace/pp/prodview-t22mebxzwjhu6'
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { DefaultModelResponse, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { ensureRerankModelSelected, isReRankModelSelected } from '../check-rerank-model'
|
||||
import { isReRankModelSelected } from '../check-rerank-model'
|
||||
|
||||
// Test data factory
|
||||
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
|
||||
@ -53,15 +53,6 @@ const createRerankModelList = (): Model[] => [
|
||||
},
|
||||
]
|
||||
|
||||
const createDefaultRerankModel = (): DefaultModelResponse => ({
|
||||
model: 'rerank-english-v2.0',
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
provider: {
|
||||
provider: 'cohere',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
},
|
||||
})
|
||||
|
||||
describe('check-rerank-model', () => {
|
||||
describe('isReRankModelSelected', () => {
|
||||
describe('Core Functionality', () => {
|
||||
@ -261,166 +252,4 @@ describe('check-rerank-model', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRerankModelSelected', () => {
|
||||
describe('Core Functionality', () => {
|
||||
it('should return original config when reranking model already selected', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should apply default model when reranking enabled but no model selected', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result.reranking_model).toEqual({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply default model for hybrid search method', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result.reranking_model).toEqual({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return original config when indexMethod is not high_quality', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'economy',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should return original config when rerankDefaultModel is null', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: null as unknown as DefaultModelResponse,
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should return original config when reranking disabled and not hybrid search', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should return original config when indexMethod is undefined', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: undefined,
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should preserve other config properties when applying default model', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
top_k: 10,
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result.top_k).toBe(10)
|
||||
expect(result.score_threshold_enabled).toBe(true)
|
||||
expect(result.score_threshold).toBe(0.8)
|
||||
expect(result.search_method).toBe(RETRIEVE_METHOD.semantic)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type {
|
||||
DefaultModelResponse,
|
||||
Model,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
@ -44,30 +43,3 @@ export const isReRankModelSelected = ({
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const ensureRerankModelSelected = ({
|
||||
rerankDefaultModel,
|
||||
indexMethod,
|
||||
retrievalConfig,
|
||||
}: {
|
||||
rerankDefaultModel: DefaultModelResponse
|
||||
retrievalConfig: RetrievalConfig
|
||||
indexMethod?: string
|
||||
}) => {
|
||||
const rerankModel = retrievalConfig.reranking_model?.reranking_model_name ? retrievalConfig.reranking_model : undefined
|
||||
if (
|
||||
indexMethod === 'high_quality'
|
||||
&& (retrievalConfig.reranking_enable || retrievalConfig.search_method === RETRIEVE_METHOD.hybrid)
|
||||
&& !rerankModel
|
||||
&& rerankDefaultModel
|
||||
) {
|
||||
return {
|
||||
...retrievalConfig,
|
||||
reranking_model: {
|
||||
reranking_provider_name: rerankDefaultModel.provider.provider,
|
||||
reranking_model_name: rerankDefaultModel.model,
|
||||
},
|
||||
}
|
||||
}
|
||||
return retrievalConfig
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from '../index'
|
||||
import { getIcon } from '../index'
|
||||
|
||||
// Mock icons
|
||||
vi.mock('../../../create/icons', () => ({
|
||||
@ -13,72 +12,10 @@ vi.mock('../../../create/icons', () => ({
|
||||
}))
|
||||
|
||||
describe('RetrievalMethodInfo', () => {
|
||||
const defaultConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'test-provider',
|
||||
reranking_model_name: 'test-model',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render correctly with full config', () => {
|
||||
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
// Check Title & Description (mocked i18n returns key prefixed with ns)
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument()
|
||||
|
||||
// Check Icon
|
||||
const icon = container.querySelector('img')
|
||||
expect(icon).toHaveAttribute('src', 'vector-icon.png')
|
||||
|
||||
// Check Config Details
|
||||
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
|
||||
expect(screen.getByText('5')).toBeInTheDocument() // Top K
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
|
||||
})
|
||||
|
||||
it('should not render reranking model if missing', () => {
|
||||
const configWithoutRerank = {
|
||||
...defaultConfig,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
}
|
||||
|
||||
render(<RetrievalMethodInfo value={configWithoutRerank} />)
|
||||
|
||||
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
|
||||
// Other fields should still be there
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle different retrieval methods', () => {
|
||||
// Test Hybrid
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
|
||||
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
describe('getIcon utility', () => {
|
||||
it('should return correct icon for each type', () => {
|
||||
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
|
||||
@ -94,33 +31,4 @@ describe('RetrievalMethodInfo', () => {
|
||||
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render score threshold if disabled', () => {
|
||||
const configWithoutScoreThreshold = {
|
||||
...defaultConfig,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
render(<RetrievalMethodInfo value={configWithoutScoreThreshold} />)
|
||||
|
||||
// score_threshold is still rendered but may be undefined
|
||||
expect(screen.queryByText('0.8')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with invertedIndex search method', () => {
|
||||
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
|
||||
const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
|
||||
// invertedIndex uses vector icon
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
|
||||
it('should render correctly with keywordSearch search method', () => {
|
||||
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
|
||||
const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
|
||||
// keywordSearch uses vector icon
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../create/icons'
|
||||
|
||||
type Props = Readonly<{
|
||||
value: RetrievalConfig
|
||||
}>
|
||||
|
||||
export const getIcon = (type: RETRIEVE_METHOD) => {
|
||||
return ({
|
||||
[RETRIEVE_METHOD.semantic]: retrievalIcon.vector,
|
||||
@ -20,45 +11,3 @@ export const getIcon = (type: RETRIEVE_METHOD) => {
|
||||
[RETRIEVE_METHOD.keywordSearch]: retrievalIcon.vector,
|
||||
})[type] || retrievalIcon.vector
|
||||
}
|
||||
|
||||
const EconomicalRetrievalMethodConfig: FC<Props> = ({
|
||||
// type,
|
||||
value,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const type = value.search_method
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RadioCard
|
||||
icon={icon}
|
||||
title={t(`retrieval.${type}.title`, { ns: 'dataset' })}
|
||||
description={t(`retrieval.${type}.description`, { ns: 'dataset' })}
|
||||
noRadio
|
||||
chosenConfigWrapClassName="pb-3!"
|
||||
chosenConfig={(
|
||||
<div className="flex flex-wrap text-xs leading-[18px] font-normal">
|
||||
{value.reranking_model.reranking_model_name && (
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="text-gray-500">{t('modelProvider.rerankModel.key', { ns: 'common' })}</div>
|
||||
<div className="font-medium text-gray-800">{value.reranking_model.reranking_model_name}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="text-gray-500">{t('datasetConfig.top_k', { ns: 'appDebug' })}</div>
|
||||
<div className="font-medium text-gray-800">{value.top_k}</div>
|
||||
</div>
|
||||
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<div className="text-gray-500">{t('datasetConfig.score_threshold', { ns: 'appDebug' })}</div>
|
||||
<div className="font-medium text-gray-800">{value.score_threshold}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(EconomicalRetrievalMethodConfig)
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FileListItemProps } from '../file-list-item'
|
||||
import type { CustomFile as File, FileItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
|
||||
import { PROGRESS_ERROR } from '../../constants'
|
||||
import FileListItem from '../file-list-item'
|
||||
|
||||
// Mock theme hook - can be changed per test
|
||||
@ -53,7 +53,7 @@ describe('FileListItem', () => {
|
||||
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
|
||||
fileID: 'file-123',
|
||||
file: createMockFile(overrides.file as Partial<File>),
|
||||
progress: PROGRESS_NOT_STARTED,
|
||||
progress: 100,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@ -139,13 +139,6 @@ describe('FileListItem', () => {
|
||||
|
||||
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show progress chart when not started (-1)', () => {
|
||||
const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED })
|
||||
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
|
||||
|
||||
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export const PROGRESS_NOT_STARTED = -1
|
||||
export const PROGRESS_ERROR = -2
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import type { CustomFile, FileItem } from '@/models/datasets'
|
||||
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
|
||||
import { PROGRESS_ERROR } from '../../constants'
|
||||
|
||||
const { mockNotify, mockToast } = vi.hoisted(() => {
|
||||
const mockNotify = vi.fn()
|
||||
@ -854,31 +854,6 @@ describe('useLocalFileUpload', () => {
|
||||
})
|
||||
|
||||
describe('file progress constants', () => {
|
||||
it('should use PROGRESS_NOT_STARTED for new files', async () => {
|
||||
mockUpload.mockResolvedValue({ id: 'file-id' })
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
||||
const event = {
|
||||
target: {
|
||||
files: [mockFile],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(event)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = mockSetLocalFileList.mock.calls[0]![0]
|
||||
expect(callArgs[0].progress).toBe(PROGRESS_NOT_STARTED)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set PROGRESS_ERROR on upload failure', async () => {
|
||||
mockUpload.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
isEmbeddingStatus,
|
||||
isTerminalStatus,
|
||||
useEmbeddingStatus,
|
||||
useInvalidateEmbeddingStatus,
|
||||
usePauseIndexing,
|
||||
useResumeIndexing,
|
||||
} from '../use-embedding-status'
|
||||
@ -386,77 +385,4 @@ describe('use-embedding-status', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInvalidateEmbeddingStatus', () => {
|
||||
it('should return a function', () => {
|
||||
const { result } = renderHook(
|
||||
() => useInvalidateEmbeddingStatus(),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current).toBe('function')
|
||||
})
|
||||
|
||||
it('should invalidate specific query when datasetId and documentId are provided', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Set some initial data in the cache
|
||||
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
|
||||
id: 'doc1',
|
||||
indexing_status: 'indexing',
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInvalidateEmbeddingStatus(),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current('ds1', 'doc1')
|
||||
})
|
||||
|
||||
// The query should be invalidated (marked as stale)
|
||||
const queryState = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
|
||||
expect(queryState?.isInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
it('should invalidate all embedding status queries when ids are not provided', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Set some initial data in the cache for multiple documents
|
||||
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
|
||||
id: 'doc1',
|
||||
indexing_status: 'indexing',
|
||||
})
|
||||
queryClient.setQueryData(['embedding', 'indexing-status', 'ds2', 'doc2'], {
|
||||
id: 'doc2',
|
||||
indexing_status: 'completed',
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInvalidateEmbeddingStatus(),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current()
|
||||
})
|
||||
|
||||
// Both queries should be invalidated
|
||||
const queryState1 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
|
||||
const queryState2 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds2', 'doc2'])
|
||||
expect(queryState1?.isInvalidated).toBe(true)
|
||||
expect(queryState2?.isInvalidated).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -129,19 +129,3 @@ export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }:
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvalidateEmbeddingStatus = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useCallback((datasetId?: string, documentId?: string) => {
|
||||
if (datasetId && documentId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'indexing-status', datasetId, documentId],
|
||||
})
|
||||
}
|
||||
else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'indexing-status'],
|
||||
})
|
||||
}
|
||||
}, [queryClient])
|
||||
}
|
||||
|
||||
@ -1,708 +0,0 @@
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Metadata, { FieldInfo } from '../index'
|
||||
|
||||
// Mock document context
|
||||
vi.mock('../../context', () => ({
|
||||
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => {
|
||||
return selector({ datasetId: 'test-dataset-id', documentId: 'test-document-id' })
|
||||
},
|
||||
}))
|
||||
|
||||
const toastMocks = vi.hoisted(() => {
|
||||
const record = vi.fn()
|
||||
const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options }))
|
||||
return {
|
||||
record,
|
||||
api: Object.assign(api, {
|
||||
success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })),
|
||||
error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })),
|
||||
warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })),
|
||||
info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
useContext: () => ({ notify: toastMocks.api }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMocks.api,
|
||||
}))
|
||||
|
||||
// Mock modifyDocMetadata
|
||||
const mockModifyDocMetadata = vi.fn()
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
|
||||
}))
|
||||
|
||||
// Mock useMetadataMap and related hooks
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
book: {
|
||||
text: 'Book',
|
||||
iconName: 'book',
|
||||
subFieldsMap: {
|
||||
title: { label: 'Title', inputType: 'input' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
author: { label: 'Author', inputType: 'input' },
|
||||
publisher: { label: 'Publisher', inputType: 'input' },
|
||||
publication_date: { label: 'Publication Date', inputType: 'input' },
|
||||
isbn: { label: 'ISBN', inputType: 'input' },
|
||||
category: { label: 'Category', inputType: 'select' },
|
||||
},
|
||||
},
|
||||
web_page: {
|
||||
text: 'Web Page',
|
||||
iconName: 'web',
|
||||
subFieldsMap: {
|
||||
title: { label: 'Title', inputType: 'input' },
|
||||
url: { label: 'URL', inputType: 'input' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
},
|
||||
},
|
||||
paper: {
|
||||
text: 'Paper',
|
||||
iconName: 'paper',
|
||||
subFieldsMap: {
|
||||
title: { label: 'Title', inputType: 'input' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
},
|
||||
},
|
||||
social_media_post: {
|
||||
text: 'Social Media Post',
|
||||
iconName: 'social',
|
||||
subFieldsMap: {
|
||||
platform: { label: 'Platform', inputType: 'input' },
|
||||
},
|
||||
},
|
||||
personal_document: {
|
||||
text: 'Personal Document',
|
||||
iconName: 'personal',
|
||||
subFieldsMap: {
|
||||
document_type: { label: 'Document Type', inputType: 'select' },
|
||||
},
|
||||
},
|
||||
business_document: {
|
||||
text: 'Business Document',
|
||||
iconName: 'business',
|
||||
subFieldsMap: {
|
||||
document_type: { label: 'Document Type', inputType: 'select' },
|
||||
},
|
||||
},
|
||||
im_chat_log: {
|
||||
text: 'IM Chat Log',
|
||||
iconName: 'chat',
|
||||
subFieldsMap: {
|
||||
platform: { label: 'Platform', inputType: 'input' },
|
||||
},
|
||||
},
|
||||
originInfo: {
|
||||
text: 'Origin Info',
|
||||
subFieldsMap: {
|
||||
data_source_type: { label: 'Data Source Type', inputType: 'input' },
|
||||
name: { label: 'Name', inputType: 'input' },
|
||||
},
|
||||
},
|
||||
technicalParameters: {
|
||||
text: 'Technical Parameters',
|
||||
subFieldsMap: {
|
||||
segment_count: { label: 'Segment Count', inputType: 'input' },
|
||||
hit_count: { label: 'Hit Count', inputType: 'input', render: (v: number, segCount?: number) => `${v}/${segCount}` },
|
||||
},
|
||||
},
|
||||
}),
|
||||
useLanguages: () => ({
|
||||
en: 'English',
|
||||
zh: 'Chinese',
|
||||
}),
|
||||
useBookCategories: () => ({
|
||||
'fiction': 'Fiction',
|
||||
'non-fiction': 'Non-Fiction',
|
||||
}),
|
||||
usePersonalDocCategories: () => ({
|
||||
resume: 'Resume',
|
||||
letter: 'Letter',
|
||||
}),
|
||||
useBusinessDocCategories: () => ({
|
||||
report: 'Report',
|
||||
proposal: 'Proposal',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
asyncRunSafe: async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
const result = await promise
|
||||
return [null, result]
|
||||
}
|
||||
catch (e) {
|
||||
return [e, null]
|
||||
}
|
||||
},
|
||||
getTextWidthWithCanvas: () => 100,
|
||||
}))
|
||||
|
||||
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
doc_type: 'book',
|
||||
doc_metadata: {
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
language: 'en',
|
||||
},
|
||||
data_source_type: 'upload_file',
|
||||
segment_count: 10,
|
||||
hit_count: 5,
|
||||
...overrides,
|
||||
} as FullDocumentDetail)
|
||||
|
||||
describe('Metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
docDetail: createMockDocDetail(),
|
||||
loading: false,
|
||||
onUpdate: vi.fn(),
|
||||
canEdit: true,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Metadata {...defaultProps} />)
|
||||
|
||||
expect(container.firstChild)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render metadata title', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/metadata\.title/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit button', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/operation\.edit/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide edit button when canEdit is false', () => {
|
||||
render(<Metadata {...defaultProps} canEdit={false} />)
|
||||
|
||||
expect(screen.queryByText(/operation\.edit/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide edit button by default when canEdit is omitted', () => {
|
||||
const { canEdit: _canEdit, ...propsWithoutCanEdit } = defaultProps
|
||||
|
||||
render(<Metadata {...propsWithoutCanEdit} />)
|
||||
|
||||
expect(screen.queryByText(/operation\.edit/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state', () => {
|
||||
render(<Metadata {...defaultProps} loading={true} />)
|
||||
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display document type icon and text', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Book'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit mode (tests useMetadataState hook integration)
|
||||
describe('Edit Mode', () => {
|
||||
it('should enter edit mode when edit button is clicked', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show change link in edit mode', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
expect(screen.getByText(/operation\.change/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cancel edit and restore values when cancel is clicked', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert - should be back to view mode
|
||||
// Assert - should be back to view mode
|
||||
expect(screen.getByText(/operation\.edit/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save metadata when save button is clicked', async () => {
|
||||
mockModifyDocMetadata.mockResolvedValueOnce({})
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockModifyDocMetadata).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success notification after successful save', async () => {
|
||||
mockModifyDocMetadata.mockResolvedValueOnce({})
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMocks.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification after failed save', async () => {
|
||||
mockModifyDocMetadata.mockRejectedValueOnce(new Error('Save failed'))
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMocks.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Document type selection (tests DocTypeSelector sub-component integration)
|
||||
describe('Document Type Selection', () => {
|
||||
it('should show doc type selection when no doc_type exists', () => {
|
||||
const docDetail = createMockDocDetail({ doc_type: '' })
|
||||
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
expect(screen.getByText(/metadata\.docTypeSelectTitle/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep doc type selection read-only when canEdit is false', () => {
|
||||
const docDetail = createMockDocDetail({ doc_type: '' })
|
||||
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} canEdit={false} />)
|
||||
|
||||
expect(screen.queryByText(/metadata\.docTypeSelectTitle/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/metadata\.firstMetaAction/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/operation\.save/i)).not.toBeInTheDocument()
|
||||
expect(mockModifyDocMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show description when no doc_type exists', () => {
|
||||
const docDetail = createMockDocDetail({ doc_type: '' })
|
||||
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
expect(screen.getByText(/metadata\.desc/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show change link in edit mode when doc_type exists', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
expect(screen.getByText(/operation\.change/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show doc type change title after clicking change', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
expect(screen.getByText(/metadata\.docTypeChangeTitle/i))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Fixed fields (tests MetadataFieldList sub-component integration)
|
||||
describe('Fixed Fields', () => {
|
||||
it('should render origin info fields', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('Data Source Type'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render technical parameters fields', () => {
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Segment Count'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Hit Count'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle doc_type as others', () => {
|
||||
const docDetail = createMockDocDetail({ doc_type: 'others' })
|
||||
|
||||
const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(container.firstChild)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined docDetail gracefully', () => {
|
||||
const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(container.firstChild)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update document type display when docDetail changes', () => {
|
||||
const { rerender } = render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Act - verify initial state shows Book
|
||||
// Act - verify initial state shows Book
|
||||
expect(screen.getByText('Book'))!.toBeInTheDocument()
|
||||
|
||||
// Update with new doc type
|
||||
const updatedDocDetail = createMockDocDetail({ doc_type: 'paper' })
|
||||
rerender(<Metadata {...defaultProps} docDetail={updatedDocDetail} />)
|
||||
|
||||
expect(screen.getByText('Paper'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// First meta action button
|
||||
describe('First Meta Action Button', () => {
|
||||
it('should show first meta action button when no doc type exists', () => {
|
||||
const docDetail = createMockDocDetail({ doc_type: '' })
|
||||
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
expect(screen.getByText(/metadata\.firstMetaAction/i))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('FieldInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultFieldInfoProps = {
|
||||
label: 'Test Label',
|
||||
value: 'Test Value',
|
||||
displayedValue: 'Test Display Value',
|
||||
}
|
||||
|
||||
// Rendering
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<FieldInfo {...defaultFieldInfoProps} />)
|
||||
|
||||
expect(container.firstChild)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label', () => {
|
||||
render(<FieldInfo {...defaultFieldInfoProps} />)
|
||||
|
||||
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render displayed value in view mode', () => {
|
||||
render(<FieldInfo {...defaultFieldInfoProps} showEdit={false} />)
|
||||
|
||||
expect(screen.getByText('Test Display Value'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit mode
|
||||
describe('Edit Mode', () => {
|
||||
it('should render input when showEdit is true and inputType is input', () => {
|
||||
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select when showEdit is true and inputType is select', () => {
|
||||
render(
|
||||
<FieldInfo
|
||||
{...defaultFieldInfoProps}
|
||||
showEdit={true}
|
||||
inputType="select"
|
||||
selectOptions={[{ value: 'opt1', name: 'Option 1' }]}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - SimpleSelect should be rendered
|
||||
// Assert - SimpleSelect should be rendered
|
||||
expect(screen.getByRole('combobox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textarea when showEdit is true and inputType is textarea', () => {
|
||||
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onUpdate when input value changes', () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Value' } })
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith('New Value')
|
||||
})
|
||||
|
||||
it('should call onUpdate when textarea value changes', () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Textarea Value' } })
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith('New Textarea Value')
|
||||
})
|
||||
})
|
||||
|
||||
// Props
|
||||
describe('Props', () => {
|
||||
it('should render value icon when provided', () => {
|
||||
render(<FieldInfo {...defaultFieldInfoProps} valueIcon={<span data-testid="value-icon">Icon</span>} />)
|
||||
|
||||
expect(screen.getByTestId('value-icon'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use defaultValue when provided', () => {
|
||||
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" defaultValue="Default" onUpdate={vi.fn()} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input)!.toHaveAttribute('placeholder')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- useMetadataState hook coverage tests (via component interactions) ---
|
||||
describe('useMetadataState coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
docDetail: createMockDocDetail(),
|
||||
loading: false,
|
||||
onUpdate: vi.fn(),
|
||||
canEdit: true,
|
||||
}
|
||||
|
||||
describe('cancelDocType', () => {
|
||||
it('should cancel doc type change and return to edit mode', () => {
|
||||
// Arrange
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode → click change to open doc type selector
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
// Now in doc type selector mode — should show cancel button
|
||||
// Now in doc type selector mode — should show cancel button
|
||||
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
|
||||
|
||||
// Act — cancel the doc type change
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert — should be back to edit mode (cancel + save buttons visible)
|
||||
// Assert — should be back to edit mode (cancel + save buttons visible)
|
||||
expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmDocType', () => {
|
||||
it('should confirm same doc type and return to edit mode keeping metadata', () => {
|
||||
// Arrange — useEffect syncs tempDocType='book' from docDetail
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode → click change to open doc type selector
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
// DocTypeSelector shows save/cancel buttons
|
||||
// DocTypeSelector shows save/cancel buttons
|
||||
expect(screen.getByText(/metadata\.docTypeChangeTitle/i))!.toBeInTheDocument()
|
||||
|
||||
// Act — click save to confirm same doc type (tempDocType='book')
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
// Assert — should return to edit mode with metadata fields visible
|
||||
// Assert — should return to edit mode with metadata fields visible
|
||||
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelEdit when no docType', () => {
|
||||
it('should show doc type selection when cancel is clicked with doc_type others', () => {
|
||||
// Arrange — doc with 'others' type normalizes to '' internally.
|
||||
// The useEffect sees doc_type='others' (truthy) and syncs state,
|
||||
// so the component initially shows view mode. Enter edit → cancel to trigger cancelEdit.
|
||||
const docDetail = createMockDocDetail({ doc_type: 'others' })
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
// 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode
|
||||
// The rendered type uses default 'book' fallback for display
|
||||
// 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode
|
||||
// The rendered type uses default 'book' fallback for display
|
||||
expect(screen.getByText(/operation\.edit/i))!.toBeInTheDocument()
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
|
||||
|
||||
// Act — cancel edit; internally docType is '' so cancelEdit goes to showDocTypes
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert — should show doc type selection since normalized docType was ''
|
||||
// Assert — should show doc type selection since normalized docType was ''
|
||||
expect(screen.getByText(/metadata\.docTypeSelectTitle/i))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMetadataField', () => {
|
||||
it('should update metadata field value via input', () => {
|
||||
// Arrange
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
// Act — find an input and change its value (Title field)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
fireEvent.change(inputs[0]!, { target: { value: 'Updated Title' } })
|
||||
|
||||
// Assert — the input should have the new value
|
||||
// Assert — the input should have the new value
|
||||
expect(inputs[0])!.toHaveValue('Updated Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveMetadata calls modifyDocMetadata with correct body', () => {
|
||||
it('should pass doc_type and doc_metadata in save request', async () => {
|
||||
// Arrange
|
||||
mockModifyDocMetadata.mockResolvedValueOnce({})
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
// Act — save
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockModifyDocMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
datasetId: 'test-dataset-id',
|
||||
documentId: 'test-document-id',
|
||||
body: expect.objectContaining({
|
||||
doc_type: 'book',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEffect sync', () => {
|
||||
it('should handle doc_metadata being null in effect sync', () => {
|
||||
// Arrange — first render with null metadata
|
||||
const { rerender } = render(
|
||||
<Metadata
|
||||
{...defaultProps}
|
||||
docDetail={createMockDocDetail({ doc_metadata: null })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act — rerender with a different doc_type to trigger useEffect sync
|
||||
rerender(
|
||||
<Metadata
|
||||
{...defaultProps}
|
||||
docDetail={createMockDocDetail({ doc_type: 'paper', doc_metadata: null })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert — should render without crashing, showing Paper type
|
||||
// Assert — should render without crashing, showing Paper type
|
||||
expect(screen.getByText('Paper'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,144 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector'
|
||||
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
book: { text: 'Book', iconName: 'book' },
|
||||
web_page: { text: 'Web Page', iconName: 'web' },
|
||||
paper: { text: 'Paper', iconName: 'paper' },
|
||||
social_media_post: { text: 'Social Media Post', iconName: 'social' },
|
||||
personal_document: { text: 'Personal Document', iconName: 'personal' },
|
||||
business_document: { text: 'Business Document', iconName: 'business' },
|
||||
wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'],
|
||||
}
|
||||
})
|
||||
|
||||
describe('DocTypeSelector', () => {
|
||||
const defaultProps = {
|
||||
docType: '' as '' | 'book',
|
||||
documentType: undefined as '' | 'book' | undefined,
|
||||
tempDocType: '' as '' | 'book' | 'web_page',
|
||||
onTempDocTypeChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify first-time setup UI (no existing doc type)
|
||||
describe('First Time Selection', () => {
|
||||
it('should render description and selection title when no doc type exists', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="" documentType={undefined} />)
|
||||
|
||||
expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon buttons for each doc type', () => {
|
||||
render(<DocTypeSelector {...defaultProps} />)
|
||||
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render confirm button disabled when tempDocType is empty', () => {
|
||||
render(<DocTypeSelector {...defaultProps} tempDocType="" />)
|
||||
|
||||
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
|
||||
expect(confirmBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render confirm button enabled when tempDocType is set', () => {
|
||||
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
|
||||
|
||||
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
|
||||
expect(confirmBtn.closest('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onConfirm when confirm button is clicked', () => {
|
||||
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
|
||||
|
||||
fireEvent.click(screen.getByText(/metadata\.firstMetaAction/))
|
||||
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify change-type UI (has existing doc type)
|
||||
describe('Change Doc Type', () => {
|
||||
it('should render change title and warning when documentType exists', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
|
||||
|
||||
expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save and cancel buttons when documentType exists', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
|
||||
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocumentTypeDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify read-only display of current doc type
|
||||
describe('Rendering', () => {
|
||||
it('should render the doc type text', () => {
|
||||
render(<DocumentTypeDisplay displayType="book" />)
|
||||
|
||||
expect(screen.getByText('Book')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show change link when showChangeLink is true', () => {
|
||||
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} />)
|
||||
|
||||
expect(screen.getByText(/operation\.change/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show change link when showChangeLink is false', () => {
|
||||
render(<DocumentTypeDisplay displayType="book" showChangeLink={false} />)
|
||||
|
||||
expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChangeClick when change link is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.change/ }))
|
||||
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fallback to "book" display when displayType is empty and no change link', () => {
|
||||
render(<DocumentTypeDisplay displayType="" showChangeLink={false} />)
|
||||
|
||||
expect(screen.getByText('Book')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,149 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MetadataFieldList from '../metadata-field-list'
|
||||
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
book: {
|
||||
text: 'Book',
|
||||
subFieldsMap: {
|
||||
title: { label: 'Title', inputType: 'input' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
author: { label: 'Author', inputType: 'input' },
|
||||
},
|
||||
},
|
||||
originInfo: {
|
||||
text: 'Origin Info',
|
||||
subFieldsMap: {
|
||||
source: { label: 'Source', inputType: 'input' },
|
||||
hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` },
|
||||
},
|
||||
},
|
||||
}),
|
||||
useLanguages: () => ({ en: 'English', zh: 'Chinese' }),
|
||||
useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }),
|
||||
usePersonalDocCategories: () => ({}),
|
||||
useBusinessDocCategories: () => ({}),
|
||||
}))
|
||||
|
||||
describe('MetadataFieldList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify rendering of metadata fields based on mainField
|
||||
describe('Rendering', () => {
|
||||
it('should render all fields for the given mainField', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
metadata={{ title: 'Test Book', language: 'en', author: 'John' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Title'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Language'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Author'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when mainField is empty', () => {
|
||||
const { container } = render(
|
||||
<MetadataFieldList mainField="" metadata={{}} />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should display "-" for missing field values', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
metadata={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// All three fields should show "-"
|
||||
const dashes = screen.getAllByText('-')
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should resolve select values to their display name', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
metadata={{ language: 'en' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('English'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify edit mode passes correct props
|
||||
describe('Edit Mode', () => {
|
||||
it('should render fields in edit mode when canEdit is true', () => {
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
canEdit={true}
|
||||
metadata={{ title: 'Book Title' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// In edit mode, FieldInfo renders input elements
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should call onFieldUpdate when a field value changes', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="book"
|
||||
canEdit={true}
|
||||
metadata={{ title: '' }}
|
||||
onFieldUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the first textbox and type in it
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
fireEvent.change(inputs[0]!, { target: { value: 'New Title' } })
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify fixed field types use docDetail as source
|
||||
describe('Fixed Field Types', () => {
|
||||
it('should use docDetail as source data for originInfo type', () => {
|
||||
const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 }
|
||||
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="originInfo"
|
||||
docDetail={docDetail as never}
|
||||
metadata={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Source'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Web'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom render function output for fields with render', () => {
|
||||
const docDetail = { source: 'API', hit_count: 15, segment_count: 5 }
|
||||
|
||||
render(
|
||||
<MetadataFieldList
|
||||
mainField="originInfo"
|
||||
docDetail={docDetail as never}
|
||||
metadata={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('15 / 5'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,166 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DocType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import s from '../style.module.css'
|
||||
|
||||
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
|
||||
return <div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
|
||||
}
|
||||
|
||||
const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{metadataMap[type].text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type DocTypeSelectorProps = {
|
||||
docType: DocType | ''
|
||||
documentType?: DocType | ''
|
||||
tempDocType: DocType | ''
|
||||
onTempDocTypeChange: (type: DocType | '') => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
docType,
|
||||
documentType,
|
||||
tempDocType,
|
||||
onTempDocTypeChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const isFirstTime = !docType && !documentType
|
||||
const currValue = tempDocType ?? documentType
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFirstTime && (
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
<FieldRoot name="document_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={currValue ?? ''}
|
||||
onValueChange={onTempDocTypeChange}
|
||||
className={s.radioGroup}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className={s.title}>
|
||||
{isFirstTime
|
||||
? t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })
|
||||
: t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}
|
||||
</FieldsetLegend>
|
||||
{documentType && (
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<FieldItem key={type}>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
s.radio,
|
||||
'focus-within:ring-2 focus-within:ring-components-input-border-hover focus-within:ring-offset-1 focus-within:outline-hidden',
|
||||
currValue === type && 'shadow-none',
|
||||
)}
|
||||
>
|
||||
<Radio
|
||||
value={type}
|
||||
aria-label={metadataMap[type].text}
|
||||
className="sr-only"
|
||||
/>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
{isFirstTime && (
|
||||
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
{documentType && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={onConfirm} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type DocumentTypeDisplayProps = {
|
||||
displayType: DocType | ''
|
||||
showChangeLink?: boolean
|
||||
onChangeClick?: () => void
|
||||
}
|
||||
|
||||
export const DocumentTypeDisplay: FC<DocumentTypeDisplayProps> = ({
|
||||
displayType,
|
||||
showChangeLink = false,
|
||||
onChangeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const effectiveType = displayType || 'book'
|
||||
|
||||
return (
|
||||
<div className={s.documentTypeShow}>
|
||||
{(displayType || !showChangeLink) && (
|
||||
<>
|
||||
<TypeIcon iconName={metadataMap[effectiveType]?.iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[effectiveType].text}
|
||||
{showChangeLink && (
|
||||
<div className="ml-1 inline-flex items-center gap-1">
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
className="inline cursor-pointer border-none bg-transparent p-0 text-left hover:text-text-accent"
|
||||
onClick={onChangeClick}
|
||||
>
|
||||
{t('operation.change', { ns: 'common' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocTypeSelector
|
||||
@ -1,88 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { metadataType } from '@/hooks/use-metadata'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
|
||||
import FieldInfo from './field-info'
|
||||
|
||||
const map2Options = (map: Record<string, string>) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key]! }))
|
||||
}
|
||||
|
||||
function useCategoryMapResolver(mainField: metadataType | '') {
|
||||
const languageMap = useLanguages()
|
||||
const bookCategoryMap = useBookCategories()
|
||||
const personalDocCategoryMap = usePersonalDocCategories()
|
||||
const businessDocCategoryMap = useBusinessDocCategories()
|
||||
|
||||
return (field: string): Record<string, string> => {
|
||||
if (field === 'language')
|
||||
return languageMap
|
||||
if (field === 'category' && mainField === 'book')
|
||||
return bookCategoryMap
|
||||
if (field === 'document_type') {
|
||||
if (mainField === 'personal_document')
|
||||
return personalDocCategoryMap
|
||||
if (mainField === 'business_document')
|
||||
return businessDocCategoryMap
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
type MetadataFieldListProps = {
|
||||
mainField: metadataType | ''
|
||||
canEdit?: boolean
|
||||
metadata?: Record<string, string>
|
||||
docDetail?: FullDocumentDetail
|
||||
onFieldUpdate?: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
const MetadataFieldList: FC<MetadataFieldListProps> = ({
|
||||
mainField,
|
||||
canEdit = false,
|
||||
metadata,
|
||||
docDetail,
|
||||
onFieldUpdate,
|
||||
}) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
const getCategoryMap = useCategoryMapResolver(mainField)
|
||||
|
||||
if (!mainField)
|
||||
return null
|
||||
|
||||
const fieldMap = metadataMap[mainField]?.subFieldsMap
|
||||
const isFixedField = ['originInfo', 'technicalParameters'].includes(mainField)
|
||||
const sourceData = isFixedField ? docDetail : metadata
|
||||
|
||||
const getDisplayValue = (field: string) => {
|
||||
const val = get(sourceData, field, '')
|
||||
if (!val && val !== 0)
|
||||
return '-'
|
||||
if (fieldMap[field]?.inputType === 'select')
|
||||
return getCategoryMap(field)[val]
|
||||
if (fieldMap[field]?.render)
|
||||
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
|
||||
return val
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(fieldMap).map(field => (
|
||||
<FieldInfo
|
||||
key={fieldMap[field]?.label}
|
||||
label={fieldMap[field]?.label!}
|
||||
displayedValue={getDisplayValue(field)}
|
||||
value={get(sourceData, field, '')}
|
||||
inputType={fieldMap[field]?.inputType || 'input'}
|
||||
showEdit={canEdit}
|
||||
onUpdate={val => onFieldUpdate?.(field, val)}
|
||||
selectOptions={map2Options(getCategoryMap(field))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataFieldList
|
||||
@ -1,175 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMetadataState } from '../use-metadata-state'
|
||||
|
||||
const { mockNotify, mockModifyDocMetadata, mockToast } = vi.hoisted(() => {
|
||||
const mockNotify = vi.fn()
|
||||
const mockToast = Object.assign(mockNotify, {
|
||||
success: vi.fn((message, options) => mockNotify({ type: 'success', message, ...options })),
|
||||
error: vi.fn((message, options) => mockNotify({ type: 'error', message, ...options })),
|
||||
warning: vi.fn((message, options) => mockNotify({ type: 'warning', message, ...options })),
|
||||
info: vi.fn((message, options) => mockNotify({ type: 'info', message, ...options })),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
})
|
||||
return { mockNotify, mockModifyDocMetadata: vi.fn(), mockToast }
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
vi.mock('../../../context', () => ({
|
||||
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) =>
|
||||
selector({ datasetId: 'ds-1', documentId: 'doc-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) }))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
asyncRunSafe: async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
return [null, await promise]
|
||||
}
|
||||
catch (e) { return [e] }
|
||||
},
|
||||
}))
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(React.Fragment, { children })
|
||||
|
||||
type DocDetail = Parameters<typeof useMetadataState>[0]['docDetail']
|
||||
|
||||
const makeDoc = (overrides: Partial<FullDocumentDetail> = {}): DocDetail =>
|
||||
({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail)
|
||||
|
||||
describe('useMetadataState', () => {
|
||||
// Verify all metadata editing workflows using a stable docDetail reference
|
||||
it('should manage the full metadata editing lifecycle', async () => {
|
||||
mockModifyDocMetadata.mockResolvedValue({ result: 'ok' })
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
// IMPORTANT: Create a stable reference outside the render callback
|
||||
// to prevent useEffect infinite loops on docDetail?.doc_metadata
|
||||
const stableDocDetail = makeDoc()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataState({ docDetail: stableDocDetail, onUpdate, canEdit: true }), { wrapper })
|
||||
|
||||
// --- Initialization ---
|
||||
expect(result.current.docType).toBe('book')
|
||||
expect(result.current.editStatus).toBe(false)
|
||||
expect(result.current.showDocTypes).toBe(false)
|
||||
expect(result.current.metadataParams.documentType).toBe('book')
|
||||
expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' })
|
||||
|
||||
// --- Enable editing ---
|
||||
act(() => {
|
||||
result.current.enableEdit()
|
||||
})
|
||||
expect(result.current.editStatus).toBe(true)
|
||||
|
||||
// --- Update individual field ---
|
||||
act(() => {
|
||||
result.current.updateMetadataField('title', 'Modified Title')
|
||||
})
|
||||
expect(result.current.metadataParams.metadata.title).toBe('Modified Title')
|
||||
expect(result.current.metadataParams.metadata.author).toBe('Author')
|
||||
|
||||
// --- Cancel edit restores original data ---
|
||||
act(() => {
|
||||
result.current.cancelEdit()
|
||||
})
|
||||
expect(result.current.metadataParams.metadata.title).toBe('Test Book')
|
||||
expect(result.current.editStatus).toBe(false)
|
||||
|
||||
// --- Doc type selection: cancel restores previous ---
|
||||
act(() => {
|
||||
result.current.enableEdit()
|
||||
})
|
||||
act(() => {
|
||||
result.current.setShowDocTypes(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setTempDocType('web_page')
|
||||
})
|
||||
act(() => {
|
||||
result.current.cancelDocType()
|
||||
})
|
||||
expect(result.current.tempDocType).toBe('book')
|
||||
expect(result.current.showDocTypes).toBe(false)
|
||||
|
||||
// --- Confirm different doc type clears metadata ---
|
||||
act(() => {
|
||||
result.current.setShowDocTypes(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setTempDocType('web_page')
|
||||
})
|
||||
act(() => {
|
||||
result.current.confirmDocType()
|
||||
})
|
||||
expect(result.current.metadataParams.documentType).toBe('web_page')
|
||||
expect(result.current.metadataParams.metadata).toEqual({})
|
||||
|
||||
// --- Save succeeds ---
|
||||
await act(async () => {
|
||||
await result.current.saveMetadata()
|
||||
})
|
||||
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-1',
|
||||
documentId: 'doc-1',
|
||||
body: { doc_type: 'web_page', doc_metadata: {} },
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
expect(result.current.editStatus).toBe(false)
|
||||
expect(result.current.saveLoading).toBe(false)
|
||||
|
||||
// --- Save failure notifies error ---
|
||||
mockNotify.mockClear()
|
||||
mockModifyDocMetadata.mockRejectedValue(new Error('fail'))
|
||||
act(() => {
|
||||
result.current.enableEdit()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.saveMetadata()
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
// Verify empty doc type starts in editing mode
|
||||
it('should initialize in editing mode when no doc type exists', () => {
|
||||
const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] })
|
||||
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail, canEdit: true }), { wrapper })
|
||||
|
||||
expect(result.current.docType).toBe('')
|
||||
expect(result.current.editStatus).toBe(true)
|
||||
expect(result.current.showDocTypes).toBe(true)
|
||||
})
|
||||
|
||||
// Verify "others" normalization
|
||||
it('should normalize "others" doc_type to empty string', () => {
|
||||
const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] })
|
||||
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
|
||||
|
||||
expect(result.current.docType).toBe('')
|
||||
})
|
||||
|
||||
// Verify undefined docDetail handling
|
||||
it('should handle undefined docDetail gracefully', () => {
|
||||
const { result } = renderHook(() => useMetadataState({ docDetail: undefined, canEdit: true }), { wrapper })
|
||||
|
||||
expect(result.current.docType).toBe('')
|
||||
expect(result.current.editStatus).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,156 +0,0 @@
|
||||
'use client'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocType, FullDocumentDetail } from '@/models/datasets'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { modifyDocMetadata } from '@/service/datasets'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { useDocumentContext } from '../../context'
|
||||
|
||||
type MetadataState = {
|
||||
documentType?: DocType | ''
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
/**
|
||||
* Normalize raw doc_type: treat 'others' as empty string.
|
||||
*/
|
||||
const normalizeDocType = (rawDocType: string): DocType | '' => {
|
||||
return rawDocType === 'others' ? '' : rawDocType as DocType | ''
|
||||
}
|
||||
type UseMetadataStateOptions = {
|
||||
docDetail?: FullDocumentDetail
|
||||
onUpdate?: () => void
|
||||
canEdit?: boolean
|
||||
}
|
||||
export function useMetadataState({ docDetail, onUpdate, canEdit = false }: UseMetadataStateOptions) {
|
||||
const { doc_metadata = {} } = docDetail || {}
|
||||
const rawDocType = docDetail?.doc_type ?? ''
|
||||
const docType = normalizeDocType(rawDocType)
|
||||
const shouldSelectDocType = canEdit && !rawDocType
|
||||
const { t } = useTranslation()
|
||||
const datasetId = useDocumentContext(s => s.datasetId)
|
||||
const documentId = useDocumentContext(s => s.documentId)
|
||||
// If no documentType yet, start in editing + showDocTypes mode
|
||||
const [editStatus, setEditStatus] = useState(shouldSelectDocType)
|
||||
const [metadataParams, setMetadataParams] = useState<MetadataState>(rawDocType
|
||||
? { documentType: docType, metadata: (doc_metadata || {}) as Record<string, string> }
|
||||
: { metadata: {} })
|
||||
const [showDocTypes, setShowDocTypes] = useState(shouldSelectDocType)
|
||||
const [tempDocType, setTempDocType] = useState<DocType | ''>('')
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
// Sync local state when the upstream docDetail changes (e.g. after save or navigation).
|
||||
// These setters are intentionally called together to batch-reset multiple pieces
|
||||
// of derived editing state that cannot be expressed as pure derived values.
|
||||
useEffect(() => {
|
||||
if (!rawDocType)
|
||||
return
|
||||
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setEditStatus(false)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setShowDocTypes(false)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setTempDocType(docType)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setMetadataParams({
|
||||
documentType: docType,
|
||||
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
|
||||
})
|
||||
}, [docDetail?.doc_metadata, docType, rawDocType])
|
||||
useEffect(() => {
|
||||
if (rawDocType && canEdit)
|
||||
return
|
||||
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setEditStatus(canEdit && !rawDocType)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setShowDocTypes(canEdit && !rawDocType)
|
||||
}, [canEdit, rawDocType])
|
||||
const updateShowDocTypes = (show: boolean) => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setShowDocTypes(show)
|
||||
}
|
||||
const confirmDocType = () => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
if (!tempDocType)
|
||||
return
|
||||
setMetadataParams({
|
||||
documentType: tempDocType,
|
||||
// Clear metadata when switching to a different doc type
|
||||
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {},
|
||||
})
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
const cancelDocType = () => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setTempDocType(metadataParams.documentType ?? '')
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
const enableEdit = () => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setEditStatus(true)
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setMetadataParams({ documentType: docType || '', metadata: { ...docDetail?.doc_metadata } })
|
||||
setEditStatus(!docType)
|
||||
if (!docType)
|
||||
setShowDocTypes(true)
|
||||
}
|
||||
const saveMetadata = async () => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setSaveLoading(true)
|
||||
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
|
||||
datasetId,
|
||||
documentId,
|
||||
body: {
|
||||
doc_type: metadataParams.documentType || docType || '',
|
||||
doc_metadata: metadataParams.metadata,
|
||||
},
|
||||
}) as Promise<CommonResponse>)
|
||||
if (!e)
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
else
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
onUpdate?.()
|
||||
setEditStatus(false)
|
||||
setSaveLoading(false)
|
||||
}
|
||||
const updateMetadataField = (field: string, value: string) => {
|
||||
if (!canEdit)
|
||||
return
|
||||
|
||||
setMetadataParams(prev => ({ ...prev, metadata: { ...prev.metadata, [field]: value } }))
|
||||
}
|
||||
return {
|
||||
docType,
|
||||
editStatus,
|
||||
showDocTypes,
|
||||
tempDocType,
|
||||
saveLoading,
|
||||
metadataParams,
|
||||
setTempDocType,
|
||||
setShowDocTypes: updateShowDocTypes,
|
||||
confirmDocType,
|
||||
cancelDocType,
|
||||
enableEdit,
|
||||
cancelEdit,
|
||||
saveMetadata,
|
||||
updateMetadataField,
|
||||
}
|
||||
}
|
||||
@ -1,129 +1,3 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { PencilIcon } from '@heroicons/react/24/outline'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import DocTypeSelector, { DocumentTypeDisplay } from './components/doc-type-selector'
|
||||
import MetadataFieldList from './components/metadata-field-list'
|
||||
import { useMetadataState } from './hooks/use-metadata-state'
|
||||
import s from './style.module.css'
|
||||
|
||||
export { default as FieldInfo } from './components/field-info'
|
||||
|
||||
type MetadataProps = {
|
||||
docDetail?: FullDocumentDetail
|
||||
loading: boolean
|
||||
onUpdate: () => void
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
const Metadata: FC<MetadataProps> = ({ docDetail, loading, onUpdate, canEdit = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
|
||||
const {
|
||||
docType,
|
||||
editStatus,
|
||||
showDocTypes,
|
||||
tempDocType,
|
||||
saveLoading,
|
||||
metadataParams,
|
||||
setTempDocType,
|
||||
setShowDocTypes,
|
||||
confirmDocType,
|
||||
cancelDocType,
|
||||
enableEdit,
|
||||
cancelEdit,
|
||||
saveMetadata,
|
||||
updateMetadataField,
|
||||
} = useMetadataState({ docDetail, onUpdate, canEdit })
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`${s.main} bg-gray-25`}>
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.main} ${editStatus ? 'bg-white' : 'bg-gray-25'}`}>
|
||||
{/* Header: title + action buttons */}
|
||||
<div className={s.titleWrapper}>
|
||||
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
|
||||
{!editStatus
|
||||
? (
|
||||
canEdit && (
|
||||
<Button onClick={enableEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
|
||||
<PencilIcon className={s.opIcon} />
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
: canEdit && !showDocTypes && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={cancelEdit} className={`${s.opBtn} ${s.opCancelBtn}`}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button onClick={saveMetadata} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary" loading={saveLoading}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document type display / selector */}
|
||||
{!editStatus
|
||||
? <DocumentTypeDisplay displayType={docType} />
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<DocumentTypeDisplay
|
||||
displayType={metadataParams.documentType || ''}
|
||||
showChangeLink={canEdit && editStatus}
|
||||
onChangeClick={() => setShowDocTypes(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Divider between type display and fields (skip when in first-time selection) */}
|
||||
{(!docType && showDocTypes) ? null : <Divider />}
|
||||
|
||||
{/* Doc type selector or editable metadata fields */}
|
||||
{canEdit && showDocTypes
|
||||
? (
|
||||
<DocTypeSelector
|
||||
docType={docType}
|
||||
documentType={metadataParams.documentType}
|
||||
tempDocType={tempDocType}
|
||||
onTempDocTypeChange={setTempDocType}
|
||||
onConfirm={confirmDocType}
|
||||
onCancel={cancelDocType}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<MetadataFieldList
|
||||
mainField={metadataParams.documentType || ''}
|
||||
canEdit={canEdit && editStatus}
|
||||
metadata={metadataParams.metadata}
|
||||
docDetail={docDetail}
|
||||
onFieldUpdate={updateMetadataField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fixed fields: origin info */}
|
||||
<Divider />
|
||||
<MetadataFieldList mainField="originInfo" docDetail={docDetail} />
|
||||
|
||||
{/* Fixed fields: technical parameters */}
|
||||
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
|
||||
<Divider />
|
||||
<MetadataFieldList mainField="technicalParameters" docDetail={docDetail} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Metadata
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Code, CodeGroup, Embed, Pre } from '../code'
|
||||
import { CodeGroup, Embed } from '../code'
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||
@ -21,31 +21,6 @@ describe('code.tsx components', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Code', () => {
|
||||
it('should render children as a code element', () => {
|
||||
render(<Code>const x = 1</Code>)
|
||||
const codeElement = screen.getByText('const x = 1')
|
||||
expect(codeElement.tagName).toBe('CODE')
|
||||
})
|
||||
|
||||
it('should pass through additional props', () => {
|
||||
render(<Code data-testid="custom-code" className="custom-class">snippet</Code>)
|
||||
const codeElement = screen.getByTestId('custom-code')
|
||||
expect(codeElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<Code>
|
||||
<span>part1</span>
|
||||
<span>part2</span>
|
||||
</Code>,
|
||||
)
|
||||
expect(screen.getByText('part1')).toBeInTheDocument()
|
||||
expect(screen.getByText('part2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embed', () => {
|
||||
it('should render value prop as a span element', () => {
|
||||
render(<Embed value="embedded content">ignored children</Embed>)
|
||||
@ -277,28 +252,6 @@ describe('code.tsx components', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre', () => {
|
||||
it('should wrap children in CodeGroup when outside CodeGroup context', () => {
|
||||
render(
|
||||
<Pre title="Pre Title">
|
||||
<pre><code>code</code></pre>
|
||||
</Pre>,
|
||||
)
|
||||
expect(screen.getByText('Pre Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return children directly when inside CodeGroup context', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="outer code">
|
||||
<Pre>
|
||||
<code>inner code</code>
|
||||
</Pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('outer code')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodePanelHeader (via CodeGroup)', () => {
|
||||
it('should render when tag is provided', () => {
|
||||
render(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from '../md'
|
||||
import { Col, Heading, Properties, Property, Row, SubProperty } from '../md'
|
||||
|
||||
describe('md.tsx components', () => {
|
||||
describe('Heading', () => {
|
||||
@ -540,67 +540,6 @@ describe('md.tsx components', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PropertyInstruction', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<PropertyInstruction>
|
||||
This is an instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
expect(screen.getByText('This is an instruction')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as li element', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction text
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
expect(container.querySelector('li')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have m-0 class', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('m-0')
|
||||
})
|
||||
|
||||
it('should have padding classes', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('px-0')
|
||||
expect(li.className).toContain('py-4')
|
||||
})
|
||||
|
||||
it('should have italic class', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('italic')
|
||||
})
|
||||
|
||||
it('should have first:pt-0 class', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('first:pt-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should render Property inside Properties', () => {
|
||||
render(
|
||||
@ -635,21 +574,5 @@ describe('md.tsx components', () => {
|
||||
expect(screen.getByText('Left column')).toBeInTheDocument()
|
||||
expect(screen.getByText('Right column')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render PropertyInstruction inside Properties', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<PropertyInstruction>
|
||||
Note: All fields are required
|
||||
</PropertyInstruction>
|
||||
<Property name="required_field" type="string" anchor={false}>
|
||||
A required field
|
||||
</Property>
|
||||
</Properties>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Note: All fields are required')).toBeInTheDocument()
|
||||
expect(screen.getByText('required_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { PropsWithChildren, ReactElement, ReactNode } from 'react'
|
||||
import type { PropsWithChildren, ReactElement } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Tabs,
|
||||
@ -9,8 +9,6 @@ import {
|
||||
} from '@langgenius/dify-ui/tabs'
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
use,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@ -269,8 +267,6 @@ function useTabGroupProps(tabValues: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const CodeGroupContext = createContext(false)
|
||||
|
||||
type CodeGroupProps = PropsWithChildren<{
|
||||
/** Code example(s) to display */
|
||||
targetCode?: string | CodeExample[]
|
||||
@ -297,42 +293,20 @@ export function CodeGroup({ children, title, targetCode, ...props }: CodeGroupPr
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<CodeGroupContext.Provider value={true}>
|
||||
{hasTabs
|
||||
? (
|
||||
<Tabs
|
||||
{...tabGroupProps}
|
||||
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
|
||||
>
|
||||
{content}
|
||||
</Tabs>
|
||||
)
|
||||
: (
|
||||
<div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</CodeGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type IChildProps = {
|
||||
children: ReactNode
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function Code({ children, ...props }: IChildProps) {
|
||||
return <code {...props}>{children}</code>
|
||||
}
|
||||
|
||||
export function Pre({ children, ...props }: IChildrenProps) {
|
||||
const isGrouped = use(CodeGroupContext)
|
||||
|
||||
if (isGrouped)
|
||||
return children
|
||||
|
||||
return <CodeGroup {...props}>{children}</CodeGroup>
|
||||
return hasTabs
|
||||
? (
|
||||
<Tabs
|
||||
{...tabGroupProps}
|
||||
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
|
||||
>
|
||||
{content}
|
||||
</Tabs>
|
||||
)
|
||||
: (
|
||||
<div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Embed({ value, ...props }: IChildrenProps) {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type IChildrenProps = {
|
||||
@ -140,9 +139,3 @@ export function SubProperty({ name, type, children }: ISubProperty) {
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertyInstruction({ children }: PropsWithChildren<{ }>) {
|
||||
return (
|
||||
<li className="m-0 px-0 py-4 italic first:pt-0">{children}</li>
|
||||
)
|
||||
}
|
||||
|
||||
@ -346,7 +346,7 @@ describe('BannerItem', () => {
|
||||
expect(wrapper).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('keeps a fixed height even when text content is empty', () => {
|
||||
it('keeps the desktop height even when text content is empty', () => {
|
||||
const banner = createMockBanner({
|
||||
content: {
|
||||
'category': '',
|
||||
@ -359,7 +359,7 @@ describe('BannerItem', () => {
|
||||
const { container } = renderBannerItem(banner)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
expect(wrapper).toHaveClass('h-[184px]')
|
||||
expect(wrapper).toHaveClass('xl:h-[184px]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -130,7 +130,7 @@ export function BannerItem({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[184px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs"
|
||||
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs xl:h-[184px]"
|
||||
onClick={handleBannerClick}
|
||||
>
|
||||
<div className="flex min-w-px flex-1 flex-col items-end self-stretch rounded-2xl py-6 pl-8">
|
||||
@ -140,7 +140,7 @@ export function BannerItem({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 py-1">
|
||||
<div className="flex w-full flex-col gap-3 py-1 max-xl:flex-1 max-xl:justify-between">
|
||||
<div
|
||||
ref={textAreaRef}
|
||||
className="grid w-full grid-cols-[minmax(0,680px)_minmax(240px,600px)] gap-x-1 max-xl:grid-cols-1"
|
||||
@ -159,11 +159,11 @@ export function BannerItem({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid w-full grid-cols-[minmax(0,680px)_minmax(240px,600px)] gap-x-1 max-xl:grid-cols-1"
|
||||
className="flex w-full items-center justify-between gap-4 pr-4 xl:grid xl:grid-cols-[minmax(0,680px)_minmax(240px,600px)] xl:gap-x-1 xl:pr-0"
|
||||
style={responsiveStyle}
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-[6px] py-1"
|
||||
className="flex min-w-0 items-center gap-[6px] py-1 max-xl:flex-1"
|
||||
style={viewMoreStyle}
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
|
||||
@ -174,7 +174,7 @@ export function BannerItem({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2 py-1 pr-10">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-2 py-1 xl:pr-10">
|
||||
{/* Slide navigation indicators */}
|
||||
<div className="flex items-center gap-1">
|
||||
{indicatorItems.map(({ id, index }) => (
|
||||
@ -196,13 +196,13 @@ export function BannerItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-60 max-w-60 shrink-0 flex-col items-end self-stretch p-2 max-lg:hidden">
|
||||
<div className="flex w-60 max-w-60 shrink-0 flex-col items-end self-stretch p-2 max-xl:w-[360px] max-xl:max-w-[360px] max-lg:hidden">
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={title}
|
||||
width={224}
|
||||
height={168}
|
||||
className="h-[168px] w-56 shrink-0 rounded-xl object-cover"
|
||||
className="h-full w-full shrink-0 rounded-xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,224 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IWorkspace } from '@/models/common'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createTestQueryClient } from '@/__tests__/utils/mock-system-features'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import WorkplaceSelector from '../index'
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
mockSwitchWorkspace: vi.fn(),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
type MockSelectState = {
|
||||
value: string
|
||||
onValueChange: (value: string | null) => void
|
||||
}
|
||||
|
||||
const selectMocks = vi.hoisted(() => ({
|
||||
state: {
|
||||
value: '',
|
||||
onValueChange: () => {},
|
||||
} as MockSelectState,
|
||||
reset: (): MockSelectState => ({
|
||||
value: '',
|
||||
onValueChange: () => {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
const workspacesQueryKey = ['console', 'workspaces', 'get'] as const
|
||||
const consoleQuery = new Proxy(actual.consoleQuery, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'workspaces') {
|
||||
return {
|
||||
get: {
|
||||
queryKey: () => workspacesQueryKey,
|
||||
queryOptions: () => ({
|
||||
queryKey: workspacesQueryKey,
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
switch: {
|
||||
post: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (variables: unknown) => toastMocks.mockSwitchWorkspace(variables),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
default: {
|
||||
notify: (args: unknown) => toastMocks.mockNotify(args),
|
||||
},
|
||||
toast: {
|
||||
success: (message: string) => toastMocks.mockNotify({ type: 'success', message }),
|
||||
error: (message: string) => toastMocks.mockNotify({ type: 'error', message }),
|
||||
warning: (message: string) => toastMocks.mockNotify({ type: 'warning', message }),
|
||||
info: (message: string) => toastMocks.mockNotify({ type: 'info', message }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Select: ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string | null) => void
|
||||
children: ReactNode
|
||||
}) => {
|
||||
selectMocks.state = { value, onValueChange }
|
||||
return <div data-testid="workplace-selector-root">{children}</div>
|
||||
},
|
||||
SelectTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<button data-testid="workplace-selector-trigger" type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="workplace-selector-content">{children}</div>
|
||||
),
|
||||
SelectGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectGroupLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode
|
||||
value: string
|
||||
}) => (
|
||||
<button
|
||||
data-testid={`workspace-option-${value}`}
|
||||
type="button"
|
||||
onClick={() => selectMocks.state.onValueChange(value)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}
|
||||
})
|
||||
|
||||
describe('WorkplaceSelector', () => {
|
||||
const defaultWorkspaces: IWorkspace[] = [
|
||||
{ id: '1', name: 'Workspace 1', current: true, plan: 'professional', status: 'normal', created_at: Date.now() },
|
||||
{ id: '2', name: 'Workspace 2', current: false, plan: 'sandbox', status: 'normal', created_at: Date.now() },
|
||||
]
|
||||
|
||||
const { mockNotify, mockSwitchWorkspace } = toastMocks
|
||||
const mockAssign = vi.fn()
|
||||
let mockWorkspaces: IWorkspace[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
selectMocks.state = selectMocks.reset()
|
||||
mockWorkspaces = defaultWorkspaces
|
||||
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkplaceSelector />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render current workspace and available workspace options', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('workplace-selector-trigger'))!.toHaveTextContent('Workspace 1')
|
||||
expect(screen.getByTestId('workspace-option-1'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workspace-option-2'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workspace-option-1'))!.toHaveTextContent('Workspace 1')
|
||||
expect(screen.getByTestId('workspace-option-2'))!.toHaveTextContent('Workspace 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace Switching', () => {
|
||||
it('should switch workspace successfully', async () => {
|
||||
mockSwitchWorkspace.mockResolvedValue({
|
||||
result: 'success',
|
||||
new_tenant: mockWorkspaces[1]!,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('workspace-option-2'))
|
||||
|
||||
await waitFor(() => expect(mockSwitchWorkspace).toHaveBeenCalledWith({
|
||||
body: { tenant_id: '2' },
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.modifiedSuccessfully',
|
||||
})
|
||||
expect(mockAssign).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not switch to the already current workspace', () => {
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('workspace-option-1'))
|
||||
|
||||
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle switching error correctly', async () => {
|
||||
mockSwitchWorkspace.mockRejectedValue(new Error('Failed'))
|
||||
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('workspace-option-2'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not crash when no workspace has current value', () => {
|
||||
mockWorkspaces = [
|
||||
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
|
||||
]
|
||||
|
||||
expect(() => renderComponent()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not crash when workspace name is empty string', () => {
|
||||
mockWorkspaces = [
|
||||
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
|
||||
]
|
||||
|
||||
expect(() => renderComponent()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,21 +1,15 @@
|
||||
import type { Plan } from '@/app/components/billing/type'
|
||||
import type { IWorkspace } from '@/models/common'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlanBadge } from '@/app/components/header/plan-badge'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
type WorkplaceSelectorContentProps = {
|
||||
workspaces: IWorkspace[]
|
||||
@ -61,52 +55,3 @@ export const WorkplaceSelectorContent = memo(({
|
||||
)
|
||||
})
|
||||
WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent'
|
||||
|
||||
const WorkplaceSelector = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: workspacesData } = useQuery(consoleQuery.workspaces.get.queryOptions())
|
||||
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
|
||||
const workspaces = workspacesData?.workspaces ?? []
|
||||
const currentWorkspace = workspaces.find(v => v.current)
|
||||
|
||||
const handleSwitchWorkspace = async (tenant_id: string) => {
|
||||
try {
|
||||
if (currentWorkspace?.id === tenant_id)
|
||||
return
|
||||
await switchWorkspaceMutation.mutateAsync({ body: { tenant_id } })
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
location.assign(`${location.origin}${basePath}`)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentWorkspace?.id ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (value)
|
||||
void handleSwitchWorkspace(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-auto cursor-pointer rounded-[10px] border-0 bg-transparent p-0.5 hover:bg-state-base-hover data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
|
||||
<span className="h-6 bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
|
||||
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden" title={currentWorkspace?.name}>
|
||||
{currentWorkspace?.name}
|
||||
</div>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<WorkplaceSelectorContent workspaces={workspaces} />
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkplaceSelector
|
||||
|
||||
@ -2,15 +2,8 @@ import {
|
||||
ACCOUNT_SETTING_MODAL_ACTION,
|
||||
ACCOUNT_SETTING_TAB,
|
||||
DEFAULT_ACCOUNT_SETTING_TAB,
|
||||
isValidAccountSettingTab,
|
||||
isValidSettingsTab,
|
||||
} from '../constants'
|
||||
import {
|
||||
enableMovedAccountSettingDestinations,
|
||||
getMovedAccountSettingDestination,
|
||||
isMovedAccountSettingTab,
|
||||
movedAccountSettingDestinations,
|
||||
} from '../destinations'
|
||||
|
||||
describe('AccountSetting Constants', () => {
|
||||
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
|
||||
@ -34,25 +27,6 @@ describe('AccountSetting Constants', () => {
|
||||
expect(DEFAULT_ACCOUNT_SETTING_TAB).toBe(ACCOUNT_SETTING_TAB.MEMBERS)
|
||||
})
|
||||
|
||||
it('isValidAccountSettingTab should return true for valid tabs', () => {
|
||||
expect(isValidAccountSettingTab('provider')).toBe(true)
|
||||
expect(isValidAccountSettingTab('members')).toBe(true)
|
||||
expect(isValidAccountSettingTab('permissions')).toBe(true)
|
||||
expect(isValidAccountSettingTab('access-rules')).toBe(true)
|
||||
expect(isValidAccountSettingTab('billing')).toBe(true)
|
||||
expect(isValidAccountSettingTab('data-source')).toBe(true)
|
||||
expect(isValidAccountSettingTab('custom-endpoint')).toBe(true)
|
||||
expect(isValidAccountSettingTab('custom')).toBe(true)
|
||||
expect(isValidAccountSettingTab('preferences')).toBe(true)
|
||||
expect(isValidAccountSettingTab('language')).toBe(true)
|
||||
})
|
||||
|
||||
it('isValidAccountSettingTab should return false for invalid tabs', () => {
|
||||
expect(isValidAccountSettingTab(null)).toBe(false)
|
||||
expect(isValidAccountSettingTab('')).toBe(false)
|
||||
expect(isValidAccountSettingTab('invalid')).toBe(false)
|
||||
})
|
||||
|
||||
it('isValidSettingsTab should include integrations tabs', () => {
|
||||
expect(isValidSettingsTab('permissions')).toBe(true)
|
||||
expect(isValidSettingsTab('access-rules')).toBe(true)
|
||||
@ -64,15 +38,4 @@ describe('AccountSetting Constants', () => {
|
||||
expect(isValidSettingsTab('agent-strategy')).toBe(true)
|
||||
expect(isValidSettingsTab('invalid')).toBe(false)
|
||||
})
|
||||
|
||||
it('should map migrated setting tabs to integrations sections', () => {
|
||||
expect(enableMovedAccountSettingDestinations).toBe(true)
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.PROVIDER]).toBe('/integrations/model-provider')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.DATA_SOURCE]).toBe('/integrations/data-source')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]).toBe('/integrations/custom-endpoint')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.PROVIDER)).toBe('/integrations/model-provider')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.DATA_SOURCE)).toBe('/integrations/data-source')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION)).toBe('/integrations/custom-endpoint')
|
||||
expect(isMovedAccountSettingTab(ACCOUNT_SETTING_TAB.BILLING)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -46,13 +46,6 @@ export const SETTINGS_TAB_VALUES = [
|
||||
] as const
|
||||
|
||||
export type SettingsTab = typeof SETTINGS_TAB_VALUES[number]
|
||||
|
||||
export const isValidAccountSettingTab = (tab: string | null): tab is AccountSettingTab => {
|
||||
if (!tab)
|
||||
return false
|
||||
return Object.values(ACCOUNT_SETTING_TAB).includes(tab as AccountSettingTab)
|
||||
}
|
||||
|
||||
export const isValidSettingsTab = (tab: string | null): tab is SettingsTab => {
|
||||
if (!tab)
|
||||
return false
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { AccountSettingTab } from './constants'
|
||||
import type { IntegrationSection } from '@/app/components/integrations/routes'
|
||||
import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
import { ACCOUNT_SETTING_TAB } from './constants'
|
||||
|
||||
export const integrationSectionByMovedAccountSettingTab = {
|
||||
@ -9,23 +8,4 @@ export const integrationSectionByMovedAccountSettingTab = {
|
||||
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: 'custom-endpoint',
|
||||
} as const satisfies Partial<Record<AccountSettingTab, IntegrationSection>>
|
||||
|
||||
export const movedAccountSettingDestinations = {
|
||||
[ACCOUNT_SETTING_TAB.PROVIDER]: buildIntegrationPath('provider'),
|
||||
[ACCOUNT_SETTING_TAB.DATA_SOURCE]: buildIntegrationPath('data-source'),
|
||||
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: buildIntegrationPath('custom-endpoint'),
|
||||
} as const satisfies Partial<Record<AccountSettingTab, string>>
|
||||
|
||||
export type MovedAccountSettingTab = keyof typeof movedAccountSettingDestinations
|
||||
|
||||
export const enableMovedAccountSettingDestinations = true
|
||||
|
||||
export const isMovedAccountSettingTab = (tab: AccountSettingTab): tab is MovedAccountSettingTab => {
|
||||
return tab in movedAccountSettingDestinations
|
||||
}
|
||||
|
||||
export const getMovedAccountSettingDestination = (tab: MovedAccountSettingTab) => {
|
||||
if (!enableMovedAccountSettingDestinations)
|
||||
return undefined
|
||||
|
||||
return movedAccountSettingDestinations[tab]
|
||||
}
|
||||
export type MovedAccountSettingTab = keyof typeof integrationSectionByMovedAccountSettingTab
|
||||
|
||||
@ -1,16 +1,4 @@
|
||||
import {
|
||||
RiErrorWarningFill,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
|
||||
export const ValidatedErrorIcon = () => {
|
||||
return <RiErrorWarningFill className="h-4 w-4 text-[#D92D20]" />
|
||||
}
|
||||
|
||||
export const ValidatedSuccessIcon = () => {
|
||||
return <CheckCircle className="h-4 w-4 text-[#039855]" />
|
||||
}
|
||||
|
||||
export const ValidatingTip = () => {
|
||||
const { t } = useTranslation()
|
||||
@ -20,14 +8,3 @@ export const ValidatingTip = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ValidatedErrorMessage = ({ errorMessage }: { errorMessage: string }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mt-2 text-xs font-normal text-[#D92D20]">
|
||||
{t('provider.validatedError', { ns: 'common' })}
|
||||
{errorMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ValidatedErrorIcon,
|
||||
ValidatedErrorMessage,
|
||||
ValidatedSuccessIcon,
|
||||
ValidatingTip,
|
||||
} from '../ValidateStatus'
|
||||
|
||||
@ -16,20 +13,4 @@ describe('ValidateStatus', () => {
|
||||
|
||||
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show translated error text with the backend message', () => {
|
||||
render(<ValidatedErrorMessage errorMessage="invalid-token" />)
|
||||
|
||||
expect(screen.getByText('common.provider.validatedErrorinvalid-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render decorative icon for success and error states', () => {
|
||||
const { container, rerender } = render(<ValidatedSuccessIcon />)
|
||||
|
||||
expect(container.firstElementChild).toBeTruthy()
|
||||
|
||||
rerender(<ValidatedErrorIcon />)
|
||||
|
||||
expect(container.firstElementChild).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ValidatedStatus } from '../declarations'
|
||||
|
||||
describe('declarations', () => {
|
||||
describe('ValidatedStatus', () => {
|
||||
it('should expose expected status values', () => {
|
||||
expect(ValidatedStatus.Success).toBe('success')
|
||||
expect(ValidatedStatus.Error).toBe('error')
|
||||
expect(ValidatedStatus.Exceed).toBe('exceed')
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user