Compare commits
3 Commits
codex/dial
...
test/real-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
070cd79c39 | ||
|
|
1d912fee41 | ||
|
|
e92ffbdf50 |
@@ -1,17 +1,15 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
from configs.remote_settings_sources.nacos.http_request import NacosHttpClient
|
||||
|
||||
|
||||
def _ok_response(text: str = "ok", json_data: dict | None = None) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.text = text
|
||||
response.raise_for_status.return_value = None
|
||||
def _ok_response(text: str = "ok", json_data: dict | None = None) -> httpx.Response:
|
||||
request = httpx.Request("GET", "http://nacos.test")
|
||||
if json_data is not None:
|
||||
response.json.return_value = json_data
|
||||
return response
|
||||
return httpx.Response(200, json=json_data, request=request)
|
||||
return httpx.Response(200, text=text, request=request)
|
||||
|
||||
|
||||
def test_http_request_passes_bounded_timeout():
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -13,6 +12,11 @@ from core.helper.marketplace import (
|
||||
get_plugin_pkg_url,
|
||||
record_install_plugin_event,
|
||||
)
|
||||
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
|
||||
|
||||
|
||||
def _response(payload: dict[str, object]) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload, request=httpx.Request("POST", "https://marketplace.test"))
|
||||
|
||||
|
||||
def test_get_plugin_pkg_url_contains_unique_identifier() -> None:
|
||||
@@ -45,22 +49,19 @@ def test_batch_fetch_plugin_by_ids_returns_empty_for_empty_input(mocker: MockerF
|
||||
|
||||
|
||||
def test_batch_fetch_plugin_by_ids_returns_plugins_from_response(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"data": {"plugins": [{"id": "p1"}]}}
|
||||
response.raise_for_status.return_value = None
|
||||
response = _response({"data": {"plugins": [{"id": "p1"}]}})
|
||||
check_status = mocker.spy(response, "raise_for_status")
|
||||
post_mock = mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
|
||||
|
||||
plugins = batch_fetch_plugin_by_ids(["p1"])
|
||||
|
||||
assert plugins == [{"id": "p1"}]
|
||||
post_mock.assert_called_once()
|
||||
response.raise_for_status.assert_called_once()
|
||||
check_status.assert_called_once()
|
||||
|
||||
|
||||
def test_batch_fetch_plugin_by_ids_rejects_invalid_plugins_response(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"data": {"plugins": ["p1"]}}
|
||||
response.raise_for_status.return_value = None
|
||||
response = _response({"data": {"plugins": ["p1"]}})
|
||||
mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
|
||||
|
||||
with pytest.raises(ValueError, match="plugins list"):
|
||||
@@ -75,9 +76,8 @@ def test_batch_fetch_plugin_manifests_returns_empty_for_empty_input(mocker: Mock
|
||||
|
||||
|
||||
def test_batch_fetch_plugin_manifests_validates_and_returns_plugins(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"data": {"plugins": [{"id": "p1"}, {"id": "p2"}]}}
|
||||
response = _response({"data": {"plugins": [{"id": "p1"}, {"id": "p2"}]}})
|
||||
check_status = mocker.spy(response, "raise_for_status")
|
||||
post_mock = mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
|
||||
validate_mock = mocker.patch(
|
||||
"core.helper.marketplace.MarketplacePluginDeclaration.model_validate",
|
||||
@@ -89,48 +89,47 @@ def test_batch_fetch_plugin_manifests_validates_and_returns_plugins(mocker: Mock
|
||||
assert result == ["manifest-1", "manifest-2"]
|
||||
post_mock.assert_called_once()
|
||||
assert validate_mock.call_count == 2
|
||||
response.raise_for_status.assert_called_once()
|
||||
check_status.assert_called_once()
|
||||
|
||||
|
||||
def test_record_install_plugin_event_posts_and_checks_status(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response = _response({})
|
||||
check_status = mocker.spy(response, "raise_for_status")
|
||||
post_mock = mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
|
||||
|
||||
record_install_plugin_event("plugin.a")
|
||||
|
||||
post_mock.assert_called_once()
|
||||
response.raise_for_status.assert_called_once()
|
||||
check_status.assert_called_once()
|
||||
|
||||
|
||||
def test_fetch_global_plugin_manifest_caches_each_plugin(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"plugins": [{"id": "a"}, {"id": "b"}]}
|
||||
snapshots = [
|
||||
MarketplacePluginSnapshot(
|
||||
org="test",
|
||||
name=name,
|
||||
latest_version="1.0.0",
|
||||
latest_package_identifier=f"test/{name}:1.0.0@checksum",
|
||||
latest_package_url=f"https://marketplace.test/{name}",
|
||||
)
|
||||
for name in ("a", "b")
|
||||
]
|
||||
response = _response({"plugins": [snapshot.model_dump() for snapshot in snapshots]})
|
||||
mocker.patch("core.helper.marketplace.httpx.get", return_value=response)
|
||||
|
||||
snapshot_a = SimpleNamespace(plugin_id="plugin-a", model_dump_json=lambda: '{"id":"a"}')
|
||||
snapshot_b = SimpleNamespace(plugin_id="plugin-b", model_dump_json=lambda: '{"id":"b"}')
|
||||
validate_mock = mocker.patch(
|
||||
"core.helper.marketplace.MarketplacePluginSnapshot.model_validate",
|
||||
side_effect=[snapshot_a, snapshot_b],
|
||||
)
|
||||
setex_mock = mocker.patch("core.helper.marketplace.redis_client.setex")
|
||||
|
||||
cached_count = fetch_global_plugin_manifest("prefix:", 60)
|
||||
|
||||
assert cached_count == 2
|
||||
assert validate_mock.call_count == 2
|
||||
setex_mock.assert_any_call(name="prefix:plugin-a", time=60, value='{"id":"a"}')
|
||||
setex_mock.assert_any_call(name="prefix:plugin-b", time=60, value='{"id":"b"}')
|
||||
for snapshot in snapshots:
|
||||
setex_mock.assert_any_call(name=f"prefix:{snapshot.plugin_id}", time=60, value=snapshot.model_dump_json())
|
||||
|
||||
|
||||
def test_fetch_global_plugin_manifest_reports_zero_for_empty_snapshot(mocker: MockerFixture) -> None:
|
||||
"""An empty snapshot must be reported so callers can fall back instead of upgrading nothing."""
|
||||
empty_plugins: list[dict[str, object]] = []
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"metadata": {"plugin_count": 0}, "plugins": empty_plugins}
|
||||
response = _response({"metadata": {"plugin_count": 0}, "plugins": empty_plugins})
|
||||
mocker.patch("core.helper.marketplace.httpx.get", return_value=response)
|
||||
setex_mock = mocker.patch("core.helper.marketplace.redis_client.setex")
|
||||
|
||||
@@ -141,9 +140,7 @@ def test_fetch_global_plugin_manifest_reports_zero_for_empty_snapshot(mocker: Mo
|
||||
|
||||
|
||||
def test_fetch_global_plugin_manifest_reports_zero_when_plugins_key_missing(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"metadata": {"plugin_count": 0}}
|
||||
response = _response({"metadata": {"plugin_count": 0}})
|
||||
mocker.patch("core.helper.marketplace.httpx.get", return_value=response)
|
||||
setex_mock = mocker.patch("core.helper.marketplace.redis_client.setex")
|
||||
|
||||
|
||||
@@ -13,13 +13,7 @@ from core.helper.trace_id_helper import (
|
||||
|
||||
|
||||
class DummyRequest:
|
||||
def __init__(
|
||||
self,
|
||||
headers: dict[str, object] | None = None,
|
||||
args: dict[str, object] | None = None,
|
||||
json: dict[str, object] | None = None,
|
||||
is_json: bool = False,
|
||||
) -> None:
|
||||
def __init__(self, headers=None, args=None, json=None, is_json=False):
|
||||
self.headers = headers or {}
|
||||
self.args = args or {}
|
||||
self.json = json
|
||||
@@ -27,21 +21,14 @@ class DummyRequest:
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
headers: dict[str, object] | None = None,
|
||||
args: dict[str, object] | None = None,
|
||||
json: dict[str, object] | None = None,
|
||||
is_json: bool = True,
|
||||
) -> None:
|
||||
def __init__(self, *, headers=None, args=None, json=None, is_json=True):
|
||||
self.headers = headers or {}
|
||||
self.args = args or {}
|
||||
self.json = json
|
||||
self.is_json = is_json
|
||||
|
||||
|
||||
def test_get_trace_session_id_prefers_header_over_query_and_body() -> None:
|
||||
def test_get_trace_session_id_prefers_header_over_query_and_body():
|
||||
request = _Request(
|
||||
headers={"X-Trace-Session-Id": " header-session "},
|
||||
args={"trace_session_id": "query-session"},
|
||||
@@ -51,7 +38,7 @@ def test_get_trace_session_id_prefers_header_over_query_and_body() -> None:
|
||||
assert get_trace_session_id(request) == "header-session"
|
||||
|
||||
|
||||
def test_get_trace_session_id_prefers_query_over_body() -> None:
|
||||
def test_get_trace_session_id_prefers_query_over_body():
|
||||
request = _Request(
|
||||
args={"trace_session_id": " query-session "},
|
||||
json={"trace_session_id": "body-session"},
|
||||
@@ -60,13 +47,13 @@ def test_get_trace_session_id_prefers_query_over_body() -> None:
|
||||
assert get_trace_session_id(request) == "query-session"
|
||||
|
||||
|
||||
def test_get_trace_session_id_reads_body_when_no_higher_priority_input() -> None:
|
||||
def test_get_trace_session_id_reads_body_when_no_higher_priority_input():
|
||||
request = _Request(json={"trace_session_id": " body/session:123 "})
|
||||
|
||||
assert get_trace_session_id(request) == "body/session:123"
|
||||
|
||||
|
||||
def test_get_trace_session_id_ignores_invalid_lower_priority_value() -> None:
|
||||
def test_get_trace_session_id_ignores_invalid_lower_priority_value():
|
||||
request = _Request(
|
||||
headers={"X-Trace-Session-Id": "header-session"},
|
||||
json={"trace_session_id": " "},
|
||||
@@ -83,14 +70,14 @@ def test_get_trace_session_id_ignores_invalid_lower_priority_value() -> None:
|
||||
_Request(headers={"X-Trace-Session-Id": "x" * 201}),
|
||||
],
|
||||
)
|
||||
def test_get_trace_session_id_rejects_invalid_highest_priority_input(trace_session_request: _Request) -> None:
|
||||
def test_get_trace_session_id_rejects_invalid_highest_priority_input(trace_session_request):
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
get_trace_session_id(trace_session_request)
|
||||
|
||||
assert "trace_session_id" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_trace_session_id_does_not_read_trace_id_or_traceparent() -> None:
|
||||
def test_get_trace_session_id_does_not_read_trace_id_or_traceparent():
|
||||
request = _Request(
|
||||
headers={
|
||||
"X-Trace-Id": "trace-id",
|
||||
@@ -103,17 +90,17 @@ def test_get_trace_session_id_does_not_read_trace_id_or_traceparent() -> None:
|
||||
assert get_trace_session_id(request) is None
|
||||
|
||||
|
||||
def test_extract_trace_session_id_from_args_returns_trimmed_value() -> None:
|
||||
def test_extract_trace_session_id_from_args_returns_trimmed_value():
|
||||
args = {"trace_session_id": " session-1 "}
|
||||
|
||||
assert extract_trace_session_id_from_args(args) == {"trace_session_id": "session-1"}
|
||||
|
||||
|
||||
def test_extract_trace_session_id_from_args_returns_empty_dict_when_missing() -> None:
|
||||
def test_extract_trace_session_id_from_args_returns_empty_dict_when_missing():
|
||||
assert extract_trace_session_id_from_args({}) == {}
|
||||
|
||||
|
||||
def test_extract_trace_session_id_from_args_returns_empty_dict_when_blank_after_trim() -> None:
|
||||
def test_extract_trace_session_id_from_args_returns_empty_dict_when_blank_after_trim():
|
||||
assert extract_trace_session_id_from_args({"trace_session_id": " "}) == {}
|
||||
|
||||
|
||||
@@ -133,26 +120,26 @@ class TestTraceIdHelper:
|
||||
("with space", False),
|
||||
],
|
||||
)
|
||||
def test_is_valid_trace_id(self, trace_id: str, expected: bool) -> None:
|
||||
def test_is_valid_trace_id(self, trace_id, expected):
|
||||
"""Test trace_id validation for various cases"""
|
||||
assert is_valid_trace_id(trace_id) is expected
|
||||
|
||||
def test_get_external_trace_id_from_header(self) -> None:
|
||||
def test_get_external_trace_id_from_header(self):
|
||||
"""Should extract valid trace_id from header"""
|
||||
req = DummyRequest(headers={"X-Trace-Id": "abc123"})
|
||||
assert get_external_trace_id(req) == "abc123"
|
||||
|
||||
def test_get_external_trace_id_from_args(self) -> None:
|
||||
def test_get_external_trace_id_from_args(self):
|
||||
"""Should extract valid trace_id from args if header missing"""
|
||||
req = DummyRequest(args={"trace_id": "abc123"})
|
||||
assert get_external_trace_id(req) == "abc123"
|
||||
|
||||
def test_get_external_trace_id_from_json(self) -> None:
|
||||
def test_get_external_trace_id_from_json(self):
|
||||
"""Should extract valid trace_id from JSON body if header and args missing"""
|
||||
req = DummyRequest(is_json=True, json={"trace_id": "abc123"})
|
||||
assert get_external_trace_id(req) == "abc123"
|
||||
|
||||
def test_get_external_trace_id_priority(self) -> None:
|
||||
def test_get_external_trace_id_priority(self):
|
||||
"""Header > args > json priority"""
|
||||
req = DummyRequest(
|
||||
headers={"X-Trace-Id": "header_id"},
|
||||
@@ -175,7 +162,7 @@ class TestTraceIdHelper:
|
||||
DummyRequest(),
|
||||
],
|
||||
)
|
||||
def test_get_external_trace_id_invalid(self, req: DummyRequest) -> None:
|
||||
def test_get_external_trace_id_invalid(self, req):
|
||||
"""Should return None for invalid or missing trace_id"""
|
||||
assert get_external_trace_id(req) is None
|
||||
|
||||
@@ -187,7 +174,7 @@ class TestTraceIdHelper:
|
||||
({}, {}),
|
||||
],
|
||||
)
|
||||
def test_extract_external_trace_id_from_args(self, args: dict[str, object], expected: dict[str, object]) -> None:
|
||||
def test_extract_external_trace_id_from_args(self, args, expected):
|
||||
"""Test extraction of external_trace_id from args mapping"""
|
||||
assert extract_external_trace_id_from_args(args) == expected
|
||||
|
||||
@@ -245,11 +232,11 @@ class TestTraceIdHelper:
|
||||
({}, {}),
|
||||
],
|
||||
)
|
||||
def test_extract_parent_trace_context_from_args(self, args: dict[str, object], expected: dict[str, object]) -> None:
|
||||
def test_extract_parent_trace_context_from_args(self, args, expected):
|
||||
"""Test extraction of parent_trace_context from args mapping"""
|
||||
assert extract_parent_trace_context_from_args(args) == expected
|
||||
|
||||
def test_extract_parent_trace_context_returns_typed_context(self) -> None:
|
||||
def test_extract_parent_trace_context_returns_typed_context(self):
|
||||
"""Parent trace context is parsed into a Pydantic value object."""
|
||||
result = extract_parent_trace_context_from_args(
|
||||
{
|
||||
@@ -267,7 +254,7 @@ class TestTraceIdHelper:
|
||||
)
|
||||
}
|
||||
|
||||
def test_extract_parent_trace_context_rejects_incomplete_typed_context(self) -> None:
|
||||
def test_extract_parent_trace_context_rejects_incomplete_typed_context(self):
|
||||
"""Typed parent trace context follows the same completeness rule as raw mappings."""
|
||||
result = extract_parent_trace_context_from_args(
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -9,7 +8,7 @@ from core.plugin.impl.asset import PluginAssetManager
|
||||
class TestPluginAssetManager:
|
||||
def test_fetch_asset_success(self, mocker: MockerFixture):
|
||||
manager = PluginAssetManager()
|
||||
response = MagicMock(status_code=200, content=b"asset-bytes")
|
||||
response = httpx.Response(status_code=200, content=b"asset-bytes")
|
||||
request_mock = mocker.patch.object(manager, "_request", return_value=response)
|
||||
|
||||
result = manager.fetch_asset("tenant-1", "asset-1")
|
||||
@@ -19,14 +18,14 @@ class TestPluginAssetManager:
|
||||
|
||||
def test_fetch_asset_not_found_raises(self, mocker: MockerFixture):
|
||||
manager = PluginAssetManager()
|
||||
mocker.patch.object(manager, "_request", return_value=MagicMock(status_code=404, content=b""))
|
||||
mocker.patch.object(manager, "_request", return_value=httpx.Response(status_code=404, content=b""))
|
||||
|
||||
with pytest.raises(ValueError, match="can not found asset asset-1"):
|
||||
manager.fetch_asset("tenant-1", "asset-1")
|
||||
|
||||
def test_extract_asset_success(self, mocker: MockerFixture):
|
||||
manager = PluginAssetManager()
|
||||
response = MagicMock(status_code=200, content=b"file-content")
|
||||
response = httpx.Response(status_code=200, content=b"file-content")
|
||||
request_mock = mocker.patch.object(manager, "_request", return_value=response)
|
||||
|
||||
result = manager.extract_asset("tenant-1", "org/plugin:1", "README.md")
|
||||
@@ -40,7 +39,7 @@ class TestPluginAssetManager:
|
||||
|
||||
def test_extract_asset_not_found_raises(self, mocker: MockerFixture):
|
||||
manager = PluginAssetManager()
|
||||
mocker.patch.object(manager, "_request", return_value=MagicMock(status_code=404, content=b""))
|
||||
mocker.patch.object(manager, "_request", return_value=httpx.Response(status_code=404, content=b""))
|
||||
|
||||
with pytest.raises(ValueError, match="can not found asset org/plugin:1, 404"):
|
||||
manager.extract_asset("tenant-1", "org/plugin:1", "README.md")
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly
|
||||
from core.model_manager import ModelManager
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_model_runtime
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.provider_manager import ProviderManager
|
||||
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
|
||||
|
||||
|
||||
def test_plugin_model_assembly_reuses_single_runtime_across_views():
|
||||
runtime = Mock(name="runtime")
|
||||
provider_factory = Mock(name="provider_factory")
|
||||
provider_manager = Mock(name="provider_manager")
|
||||
model_manager = Mock(name="model_manager")
|
||||
runtime = create_plugin_model_runtime(tenant_id="tenant-1", user_id="user-1")
|
||||
provider_factory = ModelProviderFactory(runtime=runtime)
|
||||
provider_manager = ProviderManager(model_runtime=runtime)
|
||||
model_manager = ModelManager(provider_manager=provider_manager)
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -38,8 +41,6 @@ def test_plugin_model_assembly_reuses_single_runtime_across_views():
|
||||
|
||||
|
||||
def test_create_plugin_model_runtime_injects_plugin_service():
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
|
||||
runtime = create_plugin_model_runtime(tenant_id="tenant-1", user_id="user-1")
|
||||
|
||||
assert runtime._plugin_service is PluginService
|
||||
|
||||
@@ -370,6 +370,7 @@ project-excludes = [
|
||||
"core/helper/test_credential_utils.py",
|
||||
"core/helper/test_encrypter.py",
|
||||
"core/helper/test_ssrf_proxy.py",
|
||||
"core/helper/test_trace_id_helper.py",
|
||||
"core/llm_generator/output_parser/test_rule_config_generator.py",
|
||||
"core/llm_generator/output_parser/test_structured_output.py",
|
||||
"core/llm_generator/test_llm_generator.py",
|
||||
|
||||
@@ -276,6 +276,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -761,7 +766,7 @@
|
||||
},
|
||||
"web/app/components/base/chat/chat/type.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat/utils.ts": {
|
||||
@@ -880,17 +885,25 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/audio-preview.tsx": {
|
||||
"jsx-a11y/media-has-caption": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/file-list-in-log.tsx": {
|
||||
"eslint-react/no-missing-key": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/file-preview-dialog.tsx": {
|
||||
"jsx-a11y/media-has-caption": {
|
||||
"count": 2
|
||||
"web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx": {
|
||||
"web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
@@ -926,6 +939,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/video-preview.tsx": {
|
||||
"jsx-a11y/media-has-caption": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/form/components/base/base-form.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 6
|
||||
@@ -1422,6 +1440,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -1430,6 +1456,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/image-uploader/store.tsx": {
|
||||
"react/only-export-components": {
|
||||
"count": 3
|
||||
@@ -1793,7 +1827,7 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/components/result-item.tsx": {
|
||||
"web/app/components/datasets/hit-testing/components/result-item-external.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
@@ -1801,6 +1835,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/components/result-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -2406,6 +2448,11 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": {
|
||||
"eslint-react/static-components": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow-app/components/workflow-children.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2979,6 +3026,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/http/panel.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/http/use-config.ts": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -3186,6 +3241,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/llm/components/structure-output.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/llm/use-config.ts": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -3255,6 +3315,19 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/parameter-extractor/use-config.ts": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 2
|
||||
@@ -4197,7 +4270,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/service/datasets.ts": {
|
||||
|
||||
@@ -62,8 +62,8 @@ A type is not public merely because Base UI names it or an implementation once e
|
||||
addition to matching component props, export a type only when it pairs with a public factory or a
|
||||
real consumer must name it independently.
|
||||
|
||||
State, event details and reasons, controlled-state helpers, context values, render helpers,
|
||||
styling helpers, and upstream passthrough aliases are private by default. Public props
|
||||
State, event details and reasons, actions, controlled-state helpers, context values, render
|
||||
helpers, styling helpers, and upstream passthrough aliases are private by default. Public props
|
||||
already provide contextual typing for inline render and event callbacks.
|
||||
|
||||
Preserve upstream `className` and `style` callbacks. Resolve `className` with the owning Base UI
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '../index'
|
||||
@@ -94,9 +93,7 @@ describe('AlertDialog wrapper', () => {
|
||||
<AlertDialog defaultOpen>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>Cancel</AlertDialogCancelButton>
|
||||
</AlertDialogFooter>
|
||||
<AlertDialogCancelButton>Cancel</AlertDialogCancelButton>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { AlertDialogActions, AlertDialogProps } from '@langgenius/dify-ui/alert-dialog'
|
||||
import { AlertDialog } from '@langgenius/dify-ui/alert-dialog'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function alertDialogActionsTypeContract() {
|
||||
const actionsRef = React.createRef<AlertDialogActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<AlertDialogProps['actionsRef']>['current']
|
||||
>()
|
||||
return <AlertDialog actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import * as React from 'react'
|
||||
import { expect, waitFor, within } from 'storybook/test'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '.'
|
||||
@@ -24,7 +24,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogFooter`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.',
|
||||
'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogActions`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -50,10 +50,10 @@ export const Default: Story = {
|
||||
removed.
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary">Cancel</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton>Delete</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
@@ -95,10 +95,10 @@ export const NonDestructive: Story = {
|
||||
history.
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary">Not now</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton tone="default">Publish</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
@@ -128,7 +128,7 @@ function ControlledDemo() {
|
||||
token afterwards.
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary">Keep token</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
onClick={() => {
|
||||
@@ -138,7 +138,7 @@ function ControlledDemo() {
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ function LoadingConfirmDemo() {
|
||||
finalize.
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary" disabled={pending}>
|
||||
Cancel
|
||||
</AlertDialogCancelButton>
|
||||
@@ -189,7 +189,7 @@ function LoadingConfirmDemo() {
|
||||
>
|
||||
<span id={confirmLabelId}>{pending ? 'Archiving…' : 'Archive'}</span>
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -13,8 +13,6 @@ const AlertDialogTrigger = BaseAlertDialog.Trigger
|
||||
const AlertDialogTitle = BaseAlertDialog.Title
|
||||
const AlertDialogDescription = BaseAlertDialog.Description
|
||||
|
||||
type AlertDialogActions = BaseAlertDialog.Root.Actions
|
||||
|
||||
type AlertDialogProps<Payload = unknown> = BaseAlertDialog.Root.Props<Payload>
|
||||
type AlertDialogTriggerProps<Payload = unknown> = BaseAlertDialog.Trigger.Props<Payload>
|
||||
type AlertDialogTitleProps = BaseAlertDialog.Title.Props
|
||||
@@ -61,9 +59,9 @@ function AlertDialogContent({
|
||||
)
|
||||
}
|
||||
|
||||
type AlertDialogFooterProps = React.ComponentProps<'div'>
|
||||
type AlertDialogActionsProps = React.ComponentProps<'div'>
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: AlertDialogFooterProps) {
|
||||
function AlertDialogActions({ className, ...props }: AlertDialogActionsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-start justify-end gap-2 self-stretch p-6', className)}
|
||||
@@ -92,22 +90,21 @@ function AlertDialogConfirmButton({
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
|
||||
export type {
|
||||
AlertDialogActions,
|
||||
AlertDialogActionsProps,
|
||||
AlertDialogCancelButtonProps,
|
||||
AlertDialogConfirmButtonProps,
|
||||
AlertDialogContentProps,
|
||||
AlertDialogDescriptionProps,
|
||||
AlertDialogFooterProps,
|
||||
AlertDialogProps,
|
||||
AlertDialogTitleProps,
|
||||
AlertDialogTriggerProps,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { AutocompleteActions, AutocompleteProps } from '@langgenius/dify-ui/autocomplete'
|
||||
import { Autocomplete } from '@langgenius/dify-ui/autocomplete'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function autocompleteActionsTypeContract() {
|
||||
const actionsRef = React.createRef<AutocompleteActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<AutocompleteProps<string>['actionsRef']>['current']
|
||||
>()
|
||||
return <Autocomplete actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
type AutocompleteActions = BaseAutocomplete.Root.Actions
|
||||
|
||||
type AutocompleteProps<ItemValue> = BaseAutocomplete.Root.Props<ItemValue>
|
||||
type AutocompleteChangeEventDetails = BaseAutocomplete.Root.ChangeEventDetails
|
||||
type AutocompleteGroupedProps<Items extends readonly { items: readonly unknown[] }[]> = Omit<
|
||||
@@ -430,7 +428,6 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
AutocompleteActions,
|
||||
AutocompleteChangeEventDetails,
|
||||
AutocompleteClearProps,
|
||||
AutocompleteCollectionProps,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComboboxActions, ComboboxProps } from '@langgenius/dify-ui/combobox'
|
||||
import { Combobox } from '@langgenius/dify-ui/combobox'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function comboboxActionsTypeContract() {
|
||||
const actionsRef = React.createRef<ComboboxActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<ComboboxProps<string>['actionsRef']>['current']
|
||||
>()
|
||||
return <Combobox actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
type ComboboxActions = BaseCombobox.Root.Actions
|
||||
|
||||
type ComboboxProps<
|
||||
Value,
|
||||
Multiple extends boolean | undefined = false,
|
||||
@@ -567,7 +565,6 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
ComboboxActions,
|
||||
ComboboxChangeEventDetails,
|
||||
ComboboxChipProps,
|
||||
ComboboxChipRemoveProps,
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { IconButtonProps } from '../../icon-button'
|
||||
import type {
|
||||
PopoverActions,
|
||||
PopoverContentProps,
|
||||
PopoverProps,
|
||||
PopoverTriggerProps,
|
||||
} from '../../popover'
|
||||
import type { PopoverContentProps, PopoverProps, PopoverTriggerProps } from '../../popover'
|
||||
import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../cn'
|
||||
@@ -78,7 +73,7 @@ function PickerRoot({
|
||||
const [labelId, setLabelId] = React.useState<string>()
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const actionsRef = React.useRef<PopoverActions>(null)
|
||||
const actionsRef = React.useRef<NonNullable<PopoverProps['actionsRef']>['current']>(null)
|
||||
const [validationAttempted, setValidationAttempted] = React.useState(false)
|
||||
const errorMessage = props.invalid
|
||||
? (props.validationMessage ?? 'Choose a valid value.')
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { DialogActions, DialogProps } from '@langgenius/dify-ui/dialog'
|
||||
import { Dialog } from '@langgenius/dify-ui/dialog'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function dialogActionsTypeContract() {
|
||||
const actionsRef = React.createRef<DialogActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<DialogProps['actionsRef']>['current']
|
||||
>()
|
||||
return <Dialog actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -14,8 +14,6 @@ const DialogPortal = BaseDialog.Portal
|
||||
const DialogClose = BaseDialog.Close
|
||||
const createDialogHandle = BaseDialog.createHandle
|
||||
|
||||
type DialogActions = BaseDialog.Root.Actions
|
||||
|
||||
type DialogProps<Payload = unknown> = BaseDialog.Root.Props<Payload>
|
||||
type DialogHandle<Payload = unknown> = BaseDialog.Handle<Payload>
|
||||
type DialogTriggerProps<Payload = unknown> = BaseDialog.Trigger.Props<Payload>
|
||||
@@ -102,7 +100,6 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
DialogActions,
|
||||
DialogBackdropProps,
|
||||
DialogCloseProps,
|
||||
DialogContentProps,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { DrawerActions, DrawerProps } from '@langgenius/dify-ui/drawer'
|
||||
import { Drawer } from '@langgenius/dify-ui/drawer'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function drawerActionsTypeContract() {
|
||||
const actionsRef = React.createRef<DrawerActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<DrawerProps['actionsRef']>['current']
|
||||
>()
|
||||
return <Drawer actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -16,8 +16,6 @@ const DrawerDescription = BaseDrawer.Description
|
||||
const DrawerClose = BaseDrawer.Close
|
||||
const createDrawerHandle = BaseDrawer.createHandle
|
||||
|
||||
type DrawerActions = BaseDrawer.Root.Actions
|
||||
|
||||
type DrawerProps<Payload = unknown> = BaseDrawer.Root.Props<Payload>
|
||||
type DrawerHandle<Payload = unknown> = BaseDrawer.Handle<Payload>
|
||||
type DrawerProviderProps = BaseDrawer.Provider.Props
|
||||
@@ -123,7 +121,6 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
DrawerActions,
|
||||
DrawerBackdropProps,
|
||||
DrawerCloseProps,
|
||||
DrawerContentProps,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type {
|
||||
DropdownMenuActions,
|
||||
DropdownMenuHandle,
|
||||
DropdownMenuProps,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import type { DropdownMenuHandle } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import {
|
||||
createDropdownMenuHandle,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
type Payload = { documentId: string }
|
||||
@@ -37,11 +32,3 @@ export function dropdownMenuTypeContracts() {
|
||||
)
|
||||
return { trigger, menu, invalid }
|
||||
}
|
||||
|
||||
export function dropdownMenuActionsTypeContract() {
|
||||
const actionsRef = React.createRef<DropdownMenuActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<DropdownMenuProps['actionsRef']>['current']
|
||||
>()
|
||||
return <DropdownMenu actionsRef={actionsRef} />
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ const createDropdownMenuHandle = Menu.createHandle
|
||||
|
||||
type DropdownMenuHandle<Payload = unknown> = Menu.Handle<Payload>
|
||||
|
||||
type DropdownMenuActions = Menu.Root.Actions
|
||||
|
||||
type DropdownMenuProps<Payload = unknown> = Menu.Root.Props<Payload>
|
||||
type DropdownMenuTriggerProps<Payload = unknown> = Menu.Trigger.Props<Payload>
|
||||
type DropdownMenuPortalProps = Menu.Portal.Props
|
||||
@@ -321,7 +319,6 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
DropdownMenuActions,
|
||||
DropdownMenuCheckboxItemIndicatorProps,
|
||||
DropdownMenuCheckboxItemProps,
|
||||
DropdownMenuContentProps,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { FieldActions, FieldProps } from '@langgenius/dify-ui/field'
|
||||
import { Field } from '@langgenius/dify-ui/field'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function fieldActionsTypeContract() {
|
||||
const actionsRef = React.createRef<FieldActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<NonNullable<FieldProps['actionsRef']>['current']>()
|
||||
return <Field actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import { cn } from '../cn'
|
||||
import { formLabelClassName } from '../form-control-shared'
|
||||
import { resolveClassName } from '../internals/resolve-class-name'
|
||||
|
||||
type FieldActions = BaseFieldNS.Root.Actions
|
||||
|
||||
type FieldProps = BaseFieldNS.Root.Props
|
||||
|
||||
function Field({ className, ...props }: FieldProps) {
|
||||
@@ -76,7 +74,6 @@ const FieldValidity = BaseField.Validity
|
||||
export { Field, FieldDescription, FieldError, FieldItem, FieldLabel, FieldValidity }
|
||||
|
||||
export type {
|
||||
FieldActions,
|
||||
FieldDescriptionProps,
|
||||
FieldErrorProps,
|
||||
FieldItemProps,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { InfotipActions, InfotipProps } from '@langgenius/dify-ui/infotip'
|
||||
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function infotipTypeContracts(
|
||||
triggerRef: React.Ref<HTMLButtonElement>,
|
||||
@@ -47,11 +45,3 @@ export function infotipTypeContracts(
|
||||
)
|
||||
return { composition, labelledTrigger, unnamed, children, render, state }
|
||||
}
|
||||
|
||||
export function infotipActionsTypeContract() {
|
||||
const actionsRef = React.createRef<InfotipActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<InfotipProps['actionsRef']>['current']
|
||||
>()
|
||||
return <Infotip actionsRef={actionsRef} />
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ const iconSizeClassNames = {
|
||||
|
||||
const Infotip = BasePopover.Root
|
||||
const InfotipTitle = BasePopover.Title
|
||||
type InfotipActions = BasePopover.Root.Actions
|
||||
|
||||
type InfotipProps<Payload = unknown> = BasePopover.Root.Props<Payload>
|
||||
type InfotipTitleProps = BasePopover.Title.Props
|
||||
|
||||
@@ -108,10 +106,4 @@ function InfotipContent({
|
||||
}
|
||||
|
||||
export { Infotip, InfotipContent, InfotipTitle, InfotipTrigger }
|
||||
export type {
|
||||
InfotipActions,
|
||||
InfotipContentProps,
|
||||
InfotipProps,
|
||||
InfotipTitleProps,
|
||||
InfotipTriggerProps,
|
||||
}
|
||||
export type { InfotipContentProps, InfotipProps, InfotipTitleProps, InfotipTriggerProps }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PopoverActions, PopoverProps } from '@langgenius/dify-ui/popover'
|
||||
import { Popover } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function popoverActionsTypeContract() {
|
||||
const actionsRef = React.createRef<PopoverActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<PopoverProps['actionsRef']>['current']
|
||||
>()
|
||||
return <Popover actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -17,8 +17,6 @@ const PopoverTitle = BasePopover.Title
|
||||
const PopoverDescription = BasePopover.Description
|
||||
const createPopoverHandle = BasePopover.createHandle
|
||||
|
||||
type PopoverActions = BasePopover.Root.Actions
|
||||
|
||||
type PopoverProps<Payload = unknown> = BasePopover.Root.Props<Payload>
|
||||
type PopoverArrowProps = BasePopover.Arrow.Props
|
||||
type PopoverPortalProps = BasePopover.Portal.Props
|
||||
@@ -128,7 +126,6 @@ export {
|
||||
PopoverTrigger,
|
||||
}
|
||||
export type {
|
||||
PopoverActions,
|
||||
PopoverArrowProps,
|
||||
PopoverBackdropProps,
|
||||
PopoverCloseProps,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PreviewCardActions, PreviewCardProps } from '@langgenius/dify-ui/preview-card'
|
||||
import { PreviewCard } from '@langgenius/dify-ui/preview-card'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function previewCardActionsTypeContract() {
|
||||
const actionsRef = React.createRef<PreviewCardActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<PreviewCardProps['actionsRef']>['current']
|
||||
>()
|
||||
return <PreviewCard actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -26,8 +26,6 @@ const PreviewCardTrigger = BasePreviewCard.Trigger
|
||||
const PreviewCardViewport = BasePreviewCard.Viewport
|
||||
const createPreviewCardHandle = BasePreviewCard.createHandle
|
||||
|
||||
type PreviewCardActions = BasePreviewCard.Root.Actions
|
||||
|
||||
type PreviewCardProps<Payload = unknown> = BasePreviewCard.Root.Props<Payload>
|
||||
type PreviewCardHandle<Payload = unknown> = BasePreviewCard.Handle<Payload>
|
||||
type PreviewCardTriggerProps<Payload = unknown> = BasePreviewCard.Trigger.Props<Payload>
|
||||
@@ -117,7 +115,6 @@ export {
|
||||
PreviewCardViewport,
|
||||
}
|
||||
export type {
|
||||
PreviewCardActions,
|
||||
PreviewCardContentProps,
|
||||
PreviewCardHandle,
|
||||
PreviewCardPopupProps,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SelectActions, SelectProps } from '@langgenius/dify-ui/select'
|
||||
import { Select } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function selectActionsTypeContract() {
|
||||
const actionsRef = React.createRef<SelectActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<SelectProps<string>['actionsRef']>['current']
|
||||
>()
|
||||
return <Select actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
} from '../overlay-shared'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
type SelectActions = BaseSelect.Root.Actions
|
||||
|
||||
type SelectProps<Value, Multiple extends boolean | undefined = false> = BaseSelect.Root.Props<
|
||||
Value,
|
||||
Multiple
|
||||
@@ -289,7 +287,6 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
SelectActions,
|
||||
SelectContentProps,
|
||||
SelectGroupLabelProps,
|
||||
SelectGroupProps,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { TooltipActions, TooltipProps } from '@langgenius/dify-ui/tooltip'
|
||||
import { Tooltip } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { expectTypeOf } from 'vite-plus/test'
|
||||
|
||||
export function tooltipActionsTypeContract() {
|
||||
const actionsRef = React.createRef<TooltipActions>()
|
||||
expectTypeOf(actionsRef.current).toEqualTypeOf<
|
||||
NonNullable<TooltipProps['actionsRef']>['current']
|
||||
>()
|
||||
return <Tooltip actionsRef={actionsRef} />
|
||||
}
|
||||
@@ -13,8 +13,6 @@ const Tooltip = BaseTooltip.Root
|
||||
const TooltipTrigger = BaseTooltip.Trigger
|
||||
|
||||
type TooltipProviderProps = BaseTooltip.Provider.Props
|
||||
type TooltipActions = BaseTooltip.Root.Actions
|
||||
|
||||
type TooltipProps<Payload = unknown> = BaseTooltip.Root.Props<Payload>
|
||||
type TooltipTriggerProps<Payload = unknown> = BaseTooltip.Trigger.Props<Payload>
|
||||
|
||||
@@ -56,10 +54,4 @@ function TooltipContent({
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
export type {
|
||||
TooltipActions,
|
||||
TooltipContentProps,
|
||||
TooltipProps,
|
||||
TooltipProviderProps,
|
||||
TooltipTriggerProps,
|
||||
}
|
||||
export type { TooltipContentProps, TooltipProps, TooltipProviderProps, TooltipTriggerProps }
|
||||
|
||||
@@ -14,11 +14,11 @@ import type {
|
||||
} from './type'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -855,14 +855,14 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
{t(($) => $[`${I18N_PREFIX}.removeConfirmContent`], { ns: 'app' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleRemove}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
@@ -30,7 +30,7 @@ const createAccountResponse = (name = 'Alice'): GetAccountProfileResponse => ({
|
||||
timezone: 'UTC',
|
||||
})
|
||||
|
||||
const renderPage = ({ isPasswordSet = false, passwordLoginEnabled = true } = {}) => {
|
||||
const renderPage = () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.apps.get.queryOptions({
|
||||
@@ -41,23 +41,14 @@ const renderPage = ({ isPasswordSet = false, passwordLoginEnabled = true } = {})
|
||||
|
||||
return renderWithConsoleQuery(<AccountPage />, {
|
||||
queryClient,
|
||||
accountProfile: { ...createAccountResponse(), is_password_set: isPasswordSet },
|
||||
systemFeatures: { enable_email_password_login: passwordLoginEnabled },
|
||||
accountProfile: createAccountResponse(),
|
||||
features: { education: { enabled: false } },
|
||||
})
|
||||
}
|
||||
|
||||
function pendingResponse() {
|
||||
let resolve!: (response: Response) => void
|
||||
const promise = new Promise<Response>((resolveResponse) => {
|
||||
resolve = resolveResponse
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('AccountPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.clearAllMocks()
|
||||
mocks.get.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify(createAccountResponse('Alice Cooper')), {
|
||||
@@ -89,273 +80,5 @@ describe('AccountPage', () => {
|
||||
const request = mocks.request.mock.calls[0]?.[2]?.request as Request
|
||||
expect(request.method).toBe('PATCH')
|
||||
await expect(request.json()).resolves.toEqual({ name: 'Alice Cooper' })
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getAllByText('Alice Cooper')).toHaveLength(2)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
expect(screen.getByRole('textbox', { name: 'accountSettings.account.name' })).toHaveValue(
|
||||
'Alice Cooper',
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['cancel', 'escape', 'backdrop'])(
|
||||
'discards a name draft after closing with %s',
|
||||
async (dismissal) => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
const nameInput = screen.getByRole('textbox', { name: 'accountSettings.account.name' })
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'Unsaved name')
|
||||
if (dismissal === 'cancel')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
else if (dismissal === 'escape') await user.keyboard('{Escape}')
|
||||
else await user.click(document.body)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'accountSettings.account.name' })).toHaveValue(
|
||||
'Alice',
|
||||
)
|
||||
expect(mocks.request).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the name draft after failure and allows retry through Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.request.mockRejectedValueOnce(new Error('Name update failed'))
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
const nameInput = screen.getByRole('textbox', { name: 'accountSettings.account.name' })
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'Alice Cooper{Enter}')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'accountSettings.account.editName' }),
|
||||
).toBeInTheDocument()
|
||||
expect(nameInput).toHaveValue('Alice Cooper')
|
||||
expect(mocks.request).toHaveBeenCalledOnce()
|
||||
|
||||
await user.click(nameInput)
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(mocks.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps name editing open until both saving and profile refresh finish', async () => {
|
||||
const user = userEvent.setup()
|
||||
const saving = pendingResponse()
|
||||
const refreshing = pendingResponse()
|
||||
mocks.request.mockReturnValueOnce(saving.promise)
|
||||
mocks.get.mockReturnValueOnce(refreshing.promise)
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'accountSettings.account.name' }),
|
||||
' Cooper',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'accountSettings.account.editName' })
|
||||
const cancel = within(dialog).getByRole('button', { name: 'common.operation.cancel' })
|
||||
expect(cancel).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
await user.click(cancel)
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(document.body)
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(mocks.request).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () =>
|
||||
saving.resolve(new Response(JSON.stringify(createAccountResponse('Alice Cooper')))),
|
||||
)
|
||||
await waitFor(() => expect(mocks.get).toHaveBeenCalled())
|
||||
expect(cancel).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
|
||||
await act(async () =>
|
||||
refreshing.resolve(new Response(JSON.stringify(createAccountResponse('Alice Cooper')))),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getAllByText('Alice Cooper')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'starts a fresh password session when isPasswordSet=%s',
|
||||
async (isPasswordSet) => {
|
||||
const user = userEvent.setup()
|
||||
renderPage({ isPasswordSet })
|
||||
const triggerName = isPasswordSet
|
||||
? 'accountSettings.account.resetPassword'
|
||||
: 'accountSettings.account.setPassword'
|
||||
const passwordName = isPasswordSet
|
||||
? 'accountSettings.account.newPassword'
|
||||
: 'accountSettings.account.password'
|
||||
await user.click(screen.getByRole('button', { name: triggerName }))
|
||||
|
||||
if (isPasswordSet) {
|
||||
const current = screen.getByLabelText('accountSettings.account.currentPassword')
|
||||
expect(current).toHaveAttribute('autocomplete', 'current-password')
|
||||
await user.type(current, 'Current123')
|
||||
} else {
|
||||
expect(
|
||||
screen.queryByLabelText('accountSettings.account.currentPassword'),
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
const password = screen.getByLabelText(passwordName)
|
||||
const confirm = screen.getByLabelText('accountSettings.account.confirmPassword')
|
||||
expect(password).toHaveAttribute('autocomplete', 'new-password')
|
||||
expect(confirm).toHaveAttribute('autocomplete', 'new-password')
|
||||
await user.type(password, 'NewPassword123')
|
||||
await user.type(confirm, 'NewPassword123')
|
||||
for (const toggle of screen.getAllByRole('button', { name: 'login.showPassword' })) {
|
||||
await user.click(toggle)
|
||||
}
|
||||
expect(password).toHaveAttribute('type', 'text')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: triggerName }))
|
||||
|
||||
expect(screen.getByLabelText(passwordName)).toHaveValue('')
|
||||
expect(screen.getByLabelText(passwordName)).toHaveAttribute('type', 'password')
|
||||
expect(screen.getByLabelText('accountSettings.account.confirmPassword')).toHaveValue('')
|
||||
expect(screen.getByLabelText('accountSettings.account.confirmPassword')).toHaveAttribute(
|
||||
'type',
|
||||
'password',
|
||||
)
|
||||
if (isPasswordSet) {
|
||||
expect(screen.getByLabelText('accountSettings.account.currentPassword')).toHaveValue('')
|
||||
expect(screen.getByLabelText('accountSettings.account.currentPassword')).toHaveAttribute(
|
||||
'type',
|
||||
'password',
|
||||
)
|
||||
}
|
||||
expect(mocks.request).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps all password values after a failed reset and retries the generated endpoint', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.request.mockRejectedValueOnce(new Error('Password update failed'))
|
||||
mocks.get.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ...createAccountResponse(), is_password_set: true })),
|
||||
)
|
||||
renderPage({ isPasswordSet: true })
|
||||
await user.click(screen.getByRole('button', { name: 'accountSettings.account.resetPassword' }))
|
||||
await user.type(screen.getByLabelText('accountSettings.account.currentPassword'), 'Current123')
|
||||
await user.type(screen.getByLabelText('accountSettings.account.newPassword'), 'NewPassword123')
|
||||
await user.type(
|
||||
screen.getByLabelText('accountSettings.account.confirmPassword'),
|
||||
'NewPassword123{Enter}',
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'common.operation.reset' })).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('accountSettings.account.currentPassword')).toHaveValue(
|
||||
'Current123',
|
||||
)
|
||||
expect(screen.getByLabelText('accountSettings.account.newPassword')).toHaveValue(
|
||||
'NewPassword123',
|
||||
)
|
||||
expect(screen.getByLabelText('accountSettings.account.confirmPassword')).toHaveValue(
|
||||
'NewPassword123',
|
||||
)
|
||||
expect(mocks.request).toHaveBeenCalledOnce()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.reset' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(mocks.request).toHaveBeenCalledTimes(2)
|
||||
const request = mocks.request.mock.calls[1]?.[2]?.request as Request
|
||||
expect(request.method).toBe('POST')
|
||||
expect(request.url).toContain('/account/password')
|
||||
await expect(request.json()).resolves.toEqual({
|
||||
password: 'Current123',
|
||||
new_password: 'NewPassword123',
|
||||
repeat_new_password: 'NewPassword123',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps password setting open until the refreshed profile confirms the new password', async () => {
|
||||
const user = userEvent.setup()
|
||||
const saving = pendingResponse()
|
||||
const refreshing = pendingResponse()
|
||||
mocks.request.mockReturnValueOnce(saving.promise)
|
||||
mocks.get.mockReturnValueOnce(refreshing.promise)
|
||||
renderPage()
|
||||
await user.click(screen.getByRole('button', { name: 'accountSettings.account.setPassword' }))
|
||||
await user.type(screen.getByLabelText('accountSettings.account.password'), 'NewPassword123')
|
||||
await user.type(
|
||||
screen.getByLabelText('accountSettings.account.confirmPassword'),
|
||||
'NewPassword123{Enter}',
|
||||
)
|
||||
const dialog = screen.getByRole('dialog', { name: 'accountSettings.account.setPassword' })
|
||||
const cancel = within(dialog).getByRole('button', { name: 'common.operation.cancel' })
|
||||
expect(cancel).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
await user.click(cancel)
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(document.body)
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(mocks.request).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () =>
|
||||
saving.resolve(
|
||||
new Response(JSON.stringify({ ...createAccountResponse(), is_password_set: true })),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(mocks.get).toHaveBeenCalled())
|
||||
expect(cancel).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
|
||||
await act(async () =>
|
||||
refreshing.resolve(
|
||||
new Response(JSON.stringify({ ...createAccountResponse(), is_password_set: true })),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'accountSettings.account.resetPassword' }),
|
||||
).toBeInTheDocument()
|
||||
const request = mocks.request.mock.calls[0]?.[2]?.request as Request
|
||||
await expect(request.json()).resolves.toEqual({
|
||||
password: '',
|
||||
new_password: 'NewPassword123',
|
||||
repeat_new_password: 'NewPassword123',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer password editing when password login is disabled', () => {
|
||||
renderPage({ passwordLoginEnabled: false })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'accountSettings.account.setPassword' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'accountSettings.account.resetPassword' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import { ChangePasswordDialog } from '../change-password-dialog'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ request: vi.fn() }))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
del: vi.fn(),
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
post: vi.fn(),
|
||||
request: mocks.request,
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
function pendingResponse() {
|
||||
let resolve!: (response: Response) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<Response>((resolveResponse, rejectResponse) => {
|
||||
resolve = resolveResponse
|
||||
reject = rejectResponse
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
it('keeps native submit focus during password saving and supports retry without leaving the dialog', async () => {
|
||||
// Chromium proves native Enter submission and focus preservation when pending controls change.
|
||||
const firstSave = pendingResponse()
|
||||
const retry = pendingResponse()
|
||||
mocks.request.mockReset()
|
||||
mocks.request.mockReturnValueOnce(firstSave.promise).mockReturnValueOnce(retry.promise)
|
||||
const screen = await render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<ChangePasswordDialog isPasswordSet />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'accountSettings.account.resetPassword' })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: 'accountSettings.account.resetPassword' })
|
||||
await dialog.getByLabelText('accountSettings.account.currentPassword').fill('Current123')
|
||||
await dialog.getByLabelText('accountSettings.account.newPassword').fill('NewPassword123')
|
||||
const confirmPassword = dialog.getByLabelText('accountSettings.account.confirmPassword')
|
||||
await confirmPassword.fill('NewPassword123')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
const save = dialog.getByRole('button', { name: 'common.operation.reset' })
|
||||
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(confirmPassword).toHaveFocus()
|
||||
await expect.element(confirmPassword).toHaveAttribute('readonly')
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await expect.element(confirmPassword).toHaveFocus()
|
||||
expect(mocks.request).toHaveBeenCalledOnce()
|
||||
|
||||
firstSave.reject(new Error('Password update failed'))
|
||||
await expect.element(save).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(confirmPassword).not.toHaveAttribute('readonly')
|
||||
await expect.element(confirmPassword).toHaveFocus()
|
||||
await expect.element(confirmPassword).toHaveValue('NewPassword123')
|
||||
await save.click()
|
||||
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(save).toHaveFocus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await expect.element(save).toHaveFocus()
|
||||
expect(mocks.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
retry.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'user-id',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
is_password_set: true,
|
||||
timezone: 'UTC',
|
||||
} satisfies GetAccountProfileResponse),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
})
|
||||
@@ -1,211 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccountPasswordPayload } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { DialogActions } from '@langgenius/dify-ui/dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { validPassword } from '@/config'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
|
||||
type ChangePasswordFormProps = {
|
||||
isPasswordSet: boolean
|
||||
isPending: boolean
|
||||
onSave: (password: AccountPasswordPayload) => Promise<void>
|
||||
}
|
||||
|
||||
function ChangePasswordForm({ isPasswordSet, isPending, onSave }: ChangePasswordFormProps) {
|
||||
const { t } = useTranslation(['common', 'login', 'accountSettings'])
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isPending) return
|
||||
if (!password.trim()) {
|
||||
toast.error(t(($) => $['error.passwordEmpty'], { ns: 'login' }))
|
||||
return
|
||||
}
|
||||
if (!validPassword.test(password)) {
|
||||
toast.error(t(($) => $['error.passwordInvalid'], { ns: 'login' }))
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t(($) => $['account.notEqual'], { ns: 'accountSettings' }))
|
||||
return
|
||||
}
|
||||
void onSave({
|
||||
password: currentPassword,
|
||||
new_password: password,
|
||||
repeat_new_password: confirmPassword,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="mb-6 title-2xl-semi-bold text-text-primary">
|
||||
{isPasswordSet
|
||||
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
|
||||
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
|
||||
</DialogTitle>
|
||||
{isPasswordSet && (
|
||||
<Field name="current-password">
|
||||
<FieldLabel className="system-sm-semibold">
|
||||
{t(($) => $['account.currentPassword'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onValueChange={setCurrentPassword}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
readOnly={isPending}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $[showCurrentPassword ? 'hidePassword' : 'showPassword'], {
|
||||
ns: 'login',
|
||||
})}
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
>
|
||||
<span aria-hidden="true">{showCurrentPassword ? '👀' : '😝'}</span>
|
||||
</IconButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
<Field name="new-password" className="mt-8">
|
||||
<FieldLabel className="system-sm-semibold">
|
||||
{isPasswordSet
|
||||
? t(($) => $['account.newPassword'], { ns: 'accountSettings' })
|
||||
: t(($) => $['account.password'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
readOnly={isPending}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
|
||||
ns: 'login',
|
||||
})}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
<span aria-hidden="true">{showPassword ? '👀' : '😝'}</span>
|
||||
</IconButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field name="confirm-password" className="mt-8">
|
||||
<FieldLabel className="system-sm-semibold">
|
||||
{t(($) => $['account.confirmPassword'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onValueChange={setConfirmPassword}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
readOnly={isPending}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $[showConfirmPassword ? 'hidePassword' : 'showPassword'], {
|
||||
ns: 'login',
|
||||
})}
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
<span aria-hidden="true">{showConfirmPassword ? '👀' : '😝'}</span>
|
||||
</IconButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<div className="mt-10 flex justify-end">
|
||||
<DialogClose render={<Button className="mr-2" disabled={isPending} />}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</DialogClose>
|
||||
<Button type="submit" loading={isPending} variant="primary">
|
||||
{isPasswordSet
|
||||
? t(($) => $['operation.reset'], { ns: 'common' })
|
||||
: t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ isPasswordSet }: { isPasswordSet: boolean }) {
|
||||
const { t } = useTranslation(['common', 'accountSettings'])
|
||||
const actionsRef = useRef<DialogActions>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const updatePassword = useMutation(
|
||||
consoleQuery.account.password.post.mutationOptions({
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey }),
|
||||
}),
|
||||
)
|
||||
|
||||
const handleSave = async (password: AccountPasswordPayload) => {
|
||||
try {
|
||||
await updatePassword.mutateAsync({ body: password })
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
actionsRef.current?.close()
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
actionsRef={actionsRef}
|
||||
onOpenChange={(open, details) => {
|
||||
if (!open && updatePassword.isPending && details.reason !== 'imperative-action')
|
||||
details.cancel()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button />}>
|
||||
{isPasswordSet
|
||||
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
|
||||
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-105! p-6!">
|
||||
<ChangePasswordForm
|
||||
isPasswordSet={isPasswordSet}
|
||||
isPending={updatePassword.isPending}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { DialogActions } from '@langgenius/dify-ui/dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
|
||||
type EditAccountNameFormProps = {
|
||||
name: string
|
||||
isPending: boolean
|
||||
onSave: (name: string) => Promise<void>
|
||||
}
|
||||
|
||||
function EditAccountNameForm({ name, isPending, onSave }: EditAccountNameFormProps) {
|
||||
const { t } = useTranslation(['common', 'accountSettings'])
|
||||
const [draftName, setDraftName] = useState(name)
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!isPending && draftName) void onSave(draftName)
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="mb-6 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['account.editName'], { ns: 'accountSettings' })}
|
||||
</DialogTitle>
|
||||
<Field name="name" className="gap-0">
|
||||
<FieldLabel className="py-0 system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['account.name'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={draftName}
|
||||
onValueChange={setDraftName}
|
||||
readOnly={isPending}
|
||||
/>
|
||||
</Field>
|
||||
<div className="mt-10 flex justify-end">
|
||||
<DialogClose render={<Button className="mr-2" disabled={isPending} />}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={!draftName} loading={isPending} variant="primary">
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditAccountNameDialog({ name }: { name: string }) {
|
||||
const { t } = useTranslation(['common'])
|
||||
const actionsRef = useRef<DialogActions>(null)
|
||||
const updateProfile = useMutation(consoleQuery.account.profile.patch.mutationOptions())
|
||||
|
||||
const handleSave = async (name: string) => {
|
||||
try {
|
||||
await updateProfile.mutateAsync({ body: { name } })
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully']))
|
||||
actionsRef.current?.close()
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
actionsRef={actionsRef}
|
||||
onOpenChange={(open, details) => {
|
||||
if (!open && updateProfile.isPending && details.reason !== 'imperative-action')
|
||||
details.cancel()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid">
|
||||
{t(($) => $['operation.edit'])}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-105 p-6">
|
||||
<EditAccountNameForm name={name} isPending={updateProfile.isPending} onSave={handleSave} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,19 +3,25 @@ import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { IItem } from '@/app/components/header/account-setting/collapse'
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { validPassword } from '@/config'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import DeleteAccount from '../delete-account'
|
||||
import AvatarWithEdit from './AvatarWithEdit'
|
||||
import { ChangePasswordDialog } from './change-password-dialog'
|
||||
import { EditAccountNameDialog } from './edit-account-name-dialog'
|
||||
import EmailChangeModal from './email-change-modal'
|
||||
|
||||
const titleClassName = `
|
||||
@@ -27,7 +33,8 @@ const descriptionClassName = `
|
||||
type AccountAppItem = AppPartial & IItem
|
||||
|
||||
export default function AccountPage() {
|
||||
const { t } = useTranslation(['common', 'accountSettings'])
|
||||
const { t } = useTranslation(['common', 'login', 'accountSettings'])
|
||||
const editNameInputId = useId()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appList } = useQuery(
|
||||
consoleQuery.apps.get.queryOptions({
|
||||
@@ -41,9 +48,13 @@ export default function AccountPage() {
|
||||
}),
|
||||
)
|
||||
const apps = appList?.data || []
|
||||
const queryClient = useQueryClient()
|
||||
const updateProfile = useMutation(consoleQuery.account.profile.patch.mutationOptions())
|
||||
// Cache is hydrated by CommonLayoutHydrationBoundary; this hits cache synchronously.
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const userProfile = userProfileResp.profile
|
||||
const mutateUserProfile = () =>
|
||||
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
|
||||
const { data: enableEducationPlan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
select: (features) => features.education.enabled,
|
||||
@@ -55,11 +66,86 @@ export default function AccountPage() {
|
||||
select: ({ is_student }) => is_student ?? false,
|
||||
}),
|
||||
)
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [showUpdateEmail, setShowUpdateEmail] = useState(false)
|
||||
|
||||
if (!userProfile) return null
|
||||
|
||||
const handleEditName = () => {
|
||||
setEditNameModalVisible(true)
|
||||
setEditName(userProfile.name)
|
||||
}
|
||||
const handleSaveName = async () => {
|
||||
try {
|
||||
setEditing(true)
|
||||
await updateProfile.mutateAsync({ body: { name: editName } })
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
setEditNameModalVisible(false)
|
||||
setEditing(false)
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
toast.error(message)
|
||||
}
|
||||
const valid = () => {
|
||||
if (!password.trim()) {
|
||||
showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' }))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password)) {
|
||||
showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' }))
|
||||
return false
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
showErrorMessage(t(($) => $['account.notEqual'], { ns: 'accountSettings' }))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
const resetPasswordForm = () => {
|
||||
setCurrentPassword('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
const handleSavePassword = async () => {
|
||||
if (!valid()) return
|
||||
try {
|
||||
setEditing(true)
|
||||
await updateUserProfile({
|
||||
url: 'account/password',
|
||||
body: {
|
||||
password: currentPassword,
|
||||
new_password: password,
|
||||
repeat_new_password: confirmPassword,
|
||||
},
|
||||
})
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
mutateUserProfile()
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
setEditing(false)
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
setEditPasswordModalVisible(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppItem = (item: AccountAppItem) => {
|
||||
const appIconType = zIconType.safeParse(item.icon_type).data ?? null
|
||||
return (
|
||||
@@ -108,7 +194,13 @@ export default function AccountPage() {
|
||||
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled">
|
||||
<span className="pl-1">{userProfile.name}</span>
|
||||
</div>
|
||||
<EditAccountNameDialog name={userProfile.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text"
|
||||
onClick={handleEditName}
|
||||
>
|
||||
{t(($) => $['operation.edit'], { ns: 'common' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
@@ -140,7 +232,11 @@ export default function AccountPage() {
|
||||
{t(($) => $['account.passwordTip'], { ns: 'accountSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
<ChangePasswordDialog isPasswordSet={userProfile.is_password_set} />
|
||||
<Button onClick={() => setEditPasswordModalVisible(true)}>
|
||||
{userProfile.is_password_set
|
||||
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
|
||||
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6 border border-divider-subtle" />
|
||||
@@ -168,6 +264,140 @@ export default function AccountPage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={editNameModalVisible}
|
||||
onOpenChange={(open) => !open && setEditNameModalVisible(false)}
|
||||
>
|
||||
<DialogContent className="w-105 p-6">
|
||||
<div className="mb-6 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['account.editName'], { ns: 'accountSettings' })}
|
||||
</div>
|
||||
<label htmlFor={editNameInputId} className={`block ${titleClassName}`}>
|
||||
{t(($) => $['account.name'], { ns: 'accountSettings' })}
|
||||
</label>
|
||||
<Input
|
||||
id={editNameInputId}
|
||||
className="mt-2"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
<div className="mt-10 flex justify-end">
|
||||
<Button className="mr-2" onClick={() => setEditNameModalVisible(false)}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button disabled={editing || !editName} variant="primary" onClick={handleSaveName}>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={editPasswordModalVisible}
|
||||
onOpenChange={(open) => !open && (setEditPasswordModalVisible(false), resetPasswordForm())}
|
||||
>
|
||||
<DialogContent className="w-105! p-6!">
|
||||
<div className="mb-6 title-2xl-semi-bold text-text-primary">
|
||||
{userProfile.is_password_set
|
||||
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
|
||||
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
|
||||
</div>
|
||||
{userProfile.is_password_set && (
|
||||
<Field name="current-password">
|
||||
<FieldLabel className="system-sm-semibold">
|
||||
{t(($) => $['account.currentPassword'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onValueChange={setCurrentPassword}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $[showCurrentPassword ? 'hidePassword' : 'showPassword'], {
|
||||
ns: 'login',
|
||||
})}
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
>
|
||||
<span aria-hidden="true">{showCurrentPassword ? '👀' : '😝'}</span>
|
||||
</IconButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
<Field name="new-password" className="mt-8">
|
||||
<FieldLabel className="system-sm-semibold">
|
||||
{userProfile.is_password_set
|
||||
? t(($) => $['account.newPassword'], { ns: 'accountSettings' })
|
||||
: t(($) => $['account.password'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
|
||||
ns: 'login',
|
||||
})}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
<span aria-hidden="true">{showPassword ? '👀' : '😝'}</span>
|
||||
</IconButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field name="confirm-password" className="mt-8">
|
||||
<FieldLabel className="system-sm-semibold">
|
||||
{t(($) => $['account.confirmPassword'], { ns: 'accountSettings' })}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onValueChange={setConfirmPassword}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $[showConfirmPassword ? 'hidePassword' : 'showPassword'], {
|
||||
ns: 'login',
|
||||
})}
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
<span aria-hidden="true">{showConfirmPassword ? '👀' : '😝'}</span>
|
||||
</IconButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<div className="mt-10 flex justify-end">
|
||||
<Button
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
}}
|
||||
>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button disabled={editing} variant="primary" onClick={handleSavePassword}>
|
||||
{userProfile.is_password_set
|
||||
? t(($) => $['operation.reset'], { ns: 'common' })
|
||||
: t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{showDeleteAccountModal && (
|
||||
<DeleteAccount
|
||||
onCancel={() => setShowDeleteAccountModal(false)}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -89,14 +89,14 @@ export default function AccessRulesBatchAction({
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDelete}>
|
||||
{t(($) => $['operation.sure'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -245,14 +245,14 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
|
||||
{t(($) => $['actionMsg.deleteConfirmTips'], { ns: 'appApi' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<CreatedApiKeyDialog
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { DynamicOptions, Loader } from 'next/dynamic'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { createAppDetailFixture } from '@/test/fixtures/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppInfoModals from '../app-info-modals'
|
||||
|
||||
const { loadDynamic, observeImportOpen } = vi.hoisted(() => ({
|
||||
loadDynamic: vi.fn(),
|
||||
observeImportOpen: vi.fn(),
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
const LazyComp = React.lazy(loader)
|
||||
return function DynamicWrapper(props: Record<string, unknown>) {
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(LazyComp, props),
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/dynamic')>()
|
||||
return {
|
||||
...actual,
|
||||
default: <Props,>(
|
||||
loader: DynamicOptions<Props> | Loader<Props>,
|
||||
options?: DynamicOptions<Props>,
|
||||
) => {
|
||||
if (typeof loader !== 'function') return actual.default(loader, options)
|
||||
return actual.default(() => {
|
||||
loadDynamic()
|
||||
return loader()
|
||||
}, options)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/switch-app-modal', () => ({
|
||||
default: ({ show, onClose }: { show: boolean; onClose: () => void }) =>
|
||||
show ? (
|
||||
@@ -70,27 +61,16 @@ vi.mock('@/app/components/app/duplicate-modal', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
|
||||
UpdateDSLDialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBackup,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onBackup: () => void
|
||||
}) => {
|
||||
observeImportOpen(open)
|
||||
return open ? (
|
||||
<div data-testid="import-dsl-modal">
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Cancel Import
|
||||
</button>
|
||||
<button type="button" onClick={onBackup}>
|
||||
Backup
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
},
|
||||
default: ({ onCancel, onBackup }: { onCancel: () => void; onBackup: () => void }) => (
|
||||
<div data-testid="import-dsl-modal">
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel Import
|
||||
</button>
|
||||
<button type="button" onClick={onBackup}>
|
||||
Backup
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/export-confirm-modal', () => ({
|
||||
@@ -166,24 +146,6 @@ describe('AppInfoModals', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads the import module only on first activation and retains its closed owner', async () => {
|
||||
const { rerender } = render(<AppInfoModals {...defaultProps} activeModal={null} />)
|
||||
expect(loadDynamic).not.toHaveBeenCalled()
|
||||
expect(observeImportOpen).not.toHaveBeenCalled()
|
||||
|
||||
rerender(<AppInfoModals {...defaultProps} activeModal="importDSL" />)
|
||||
expect(await screen.findByTestId('import-dsl-modal')).toBeInTheDocument()
|
||||
expect(loadDynamic).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<AppInfoModals {...defaultProps} activeModal={null} />)
|
||||
expect(observeImportOpen).toHaveBeenLastCalledWith(false)
|
||||
expect(screen.queryByTestId('import-dsl-modal')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<AppInfoModals {...defaultProps} activeModal="importDSL" />)
|
||||
expect(await screen.findByTestId('import-dsl-modal')).toBeInTheDocument()
|
||||
expect(loadDynamic).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render nothing when activeModal is null', async () => {
|
||||
await act(async () => {
|
||||
render(<AppInfoModals {...defaultProps} activeModal={null} />)
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -32,13 +32,9 @@ const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const UpdateDSLDialog = dynamic(
|
||||
() =>
|
||||
import('@/app/components/workflow/update-dsl-modal').then((module) => module.UpdateDSLDialog),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
)
|
||||
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type AppInfoModalsProps = {
|
||||
appDetail: AppDetailWithSite
|
||||
@@ -71,10 +67,6 @@ const AppInfoModals = ({
|
||||
}: AppInfoModalsProps) => {
|
||||
const { t } = useTranslation(['app', 'common', 'workflow'])
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const [hasActivatedImportDialog, setHasActivatedImportDialog] = useState(
|
||||
() => activeModal === 'importDSL',
|
||||
)
|
||||
if (activeModal === 'importDSL' && !hasActivatedImportDialog) setHasActivatedImportDialog(true)
|
||||
const exportConfirmLabelId = React.useId()
|
||||
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
|
||||
const exportDialogMode =
|
||||
@@ -193,25 +185,22 @@ const AppInfoModals = ({
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton type="button">
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton type="submit" disabled={isDeleteConfirmDisabled}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{hasActivatedImportDialog && (
|
||||
<UpdateDSLDialog
|
||||
open={activeModal === 'importDSL'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeModal()
|
||||
}}
|
||||
{activeModal === 'importDSL' && (
|
||||
<UpdateDSLModal
|
||||
appId={appDetail.id}
|
||||
appMode={appDetail.mode}
|
||||
onCancel={closeModal}
|
||||
onBackup={exportCheck}
|
||||
/>
|
||||
)}
|
||||
@@ -234,7 +223,7 @@ const AppInfoModals = ({
|
||||
{t(($) => $['sidebar.exportWarningDesc'], { ns: 'workflow' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -250,7 +239,7 @@ const AppInfoModals = ({
|
||||
: t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</span>
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -238,14 +238,14 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => {
|
||||
{confirmMessage}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirmDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import {
|
||||
@@ -196,7 +196,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
{t(($) => $.deleteConfirmContent)}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter className="pt-0">
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -206,7 +206,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
>
|
||||
{t(($) => $['menu.deleteSnippet'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -57,8 +57,9 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
|
||||
vi.mock('../shared/use-access-point-actions', () => ({
|
||||
useAccessPointActions: () => ({
|
||||
handleResult: vi.fn(),
|
||||
refreshAppDetail: vi.fn(),
|
||||
saveSiteConfig: vi.fn().mockResolvedValue(true),
|
||||
saveSiteConfig: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { QueryClientTestProvider } from '@/test/console/query-provider'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import { EnvironmentServiceApiCard } from '../deployed-environment-access-points/environment-service-api-card'
|
||||
import { EnvironmentWebAppCard } from '../deployed-environment-access-points/environment-web-app-card'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getApi: vi.fn(),
|
||||
saveSiteConfig: vi.fn(),
|
||||
getSite: vi.fn(),
|
||||
getSubjects: vi.fn(),
|
||||
resetSite: vi.fn(),
|
||||
@@ -49,7 +45,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
icon_type: 'emoji',
|
||||
icon_url: null,
|
||||
site: {
|
||||
...createAppSiteFixture(),
|
||||
access_token: 'built-in-code',
|
||||
app_base_url: 'https://built-in.example.test',
|
||||
},
|
||||
@@ -63,10 +58,24 @@ vi.mock('@/app/components/base/app-icon', () => ({
|
||||
|
||||
vi.mock('@/app/components/app/access-point/shared/use-access-point-actions', () => ({
|
||||
useAccessPointActions: () => ({
|
||||
saveSiteConfig: mocks.saveSiteConfig,
|
||||
saveSiteConfig: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/customize', () => ({
|
||||
default: ({ api_base_url, isShow }: { api_base_url: string; isShow: boolean }) =>
|
||||
isShow ? (
|
||||
<div role="dialog" aria-label="environment customize">
|
||||
{api_base_url}
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/settings', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div role="dialog" aria-label="environment settings" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('../deployed-environment-access-points/environment-access-control', () => ({
|
||||
EnvironmentAccessControl: (props: {
|
||||
appId: string
|
||||
@@ -122,19 +131,13 @@ const api = {
|
||||
}
|
||||
|
||||
function renderCard(ui: ReactElement, queryClient = createTestQueryClient()) {
|
||||
seedFeatures(queryClient)
|
||||
seedSystemFeatures(queryClient)
|
||||
queryClient.setQueryData(['system-features'], {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter>
|
||||
<QueryClientTestProvider queryClient={queryClient}>{ui}</QueryClientTestProvider>
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
@@ -154,7 +157,6 @@ afterAll(() => vi.unstubAllGlobals())
|
||||
describe('environment access point cards', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.saveSiteConfig.mockResolvedValue(true)
|
||||
mockAppMode = 'workflow'
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
@@ -407,27 +409,12 @@ describe('environment access point cards', () => {
|
||||
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled(),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /customize\.entry/ }))
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'appOverview.overview.appInfo.customize.title' }),
|
||||
).toHaveTextContent('https://api.example.test/v1')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent(
|
||||
'https://api.example.test/v1',
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /settings\.settings/ }))
|
||||
const settings = await screen.findByRole('dialog', {
|
||||
name: 'appOverview.overview.appInfo.settings.title',
|
||||
})
|
||||
await user.clear(within(settings).getByPlaceholderText('app.appNamePlaceholder'))
|
||||
await user.type(
|
||||
within(settings).getByPlaceholderText('app.appNamePlaceholder'),
|
||||
'Environment portal',
|
||||
)
|
||||
await user.click(within(settings).getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(mocks.saveSiteConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Environment portal' }),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps view actions available while disabling deployed Web App management', async () => {
|
||||
|
||||
@@ -3,30 +3,45 @@ import type {
|
||||
AppSiteUpdatePayload,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { createQueryClientWrapper } from '@/test/console/query-client'
|
||||
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { createAppDetailFixture } from '@/test/fixtures/app'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import { useAccessPointActions } from '../shared/use-access-point-actions'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchAppDetail: vi.fn(),
|
||||
setAppDetail: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
updateAppSiteConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/notifications', () => ({ toast: mocks.toast }))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: {
|
||||
apps: { byAppId: { get: mocks.fetchAppDetail, site: { post: mocks.updateAppSiteConfig } } },
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppDetail: typeof mocks.setAppDetail }) => unknown) =>
|
||||
selector({ setAppDetail: mocks.setAppDetail }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleClient: {
|
||||
apps: { byAppId: { get: mocks.fetchAppDetail, site: { post: mocks.updateAppSiteConfig } } },
|
||||
},
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
get: { key: () => ['apps'] },
|
||||
recent: { get: { key: () => ['apps', 'recent'] } },
|
||||
starred: { get: { key: () => ['apps', 'starred'] } },
|
||||
byAppId: {
|
||||
get: {
|
||||
queryKey: ({ input }: { input: { params: { app_id: string } } }) => [
|
||||
'app-detail',
|
||||
input.params.app_id,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const siteConfig = {
|
||||
chat_color_theme: '#000000',
|
||||
@@ -54,23 +69,10 @@ function renderActions(appId = 'app-1', canManageAccessPoint = true) {
|
||||
return { ...rendered, queryClient }
|
||||
}
|
||||
|
||||
function detailKey(appId = 'app-1') {
|
||||
return consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appId } } })
|
||||
}
|
||||
|
||||
function deferredDetail() {
|
||||
let resolve!: (value: ReturnType<typeof createAppDetailFixture>) => void
|
||||
const promise = new Promise<ReturnType<typeof createAppDetailFixture>>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useAccessPointActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.getState().setAppDetail(createAppDetailFixture({ id: 'app-1' }))
|
||||
mocks.fetchAppDetail.mockResolvedValue(createAppDetailFixture({ id: 'app-1' }))
|
||||
mocks.fetchAppDetail.mockResolvedValue(createAppDetailFixture())
|
||||
mocks.updateAppSiteConfig.mockResolvedValue({
|
||||
app_id: 'app-1',
|
||||
customize_token_strategy: 'not_allow',
|
||||
@@ -82,86 +84,57 @@ describe('useAccessPointActions', () => {
|
||||
} satisfies AppSiteResponse)
|
||||
})
|
||||
|
||||
afterEach(() => useAppStore.getState().setAppDetail(undefined))
|
||||
it('refreshes after a successful access point result', async () => {
|
||||
const { result } = renderActions()
|
||||
|
||||
it('succeeds after POST while detail refresh continues in the background', async () => {
|
||||
const detail = deferredDetail()
|
||||
mocks.fetchAppDetail.mockReturnValueOnce(detail.promise)
|
||||
const updated = createAppDetailFixture({
|
||||
id: 'app-1',
|
||||
site: createAppSiteFixture({ title: 'Saved portal' }),
|
||||
act(() => result.current.handleResult(null))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
|
||||
expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture())
|
||||
})
|
||||
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully', {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a failed result without refreshing stale state', () => {
|
||||
const { result } = renderActions()
|
||||
|
||||
act(() => result.current.handleResult(new Error('request failed')))
|
||||
|
||||
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
|
||||
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully', {
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
|
||||
it('invalidates app detail and lists after saving site configuration', async () => {
|
||||
const { queryClient, result } = renderActions()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSiteConfig(siteConfig)
|
||||
})
|
||||
const { queryClient, result } = renderActions()
|
||||
const previous = useAppStore.getState().appDetail
|
||||
queryClient.setQueryData(detailKey(), previous)
|
||||
|
||||
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
|
||||
expect(mocks.updateAppSiteConfig).toHaveBeenCalledWith({
|
||||
params: { app_id: 'app-1' },
|
||||
body: siteConfig,
|
||||
})
|
||||
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
|
||||
expect(useAppStore.getState().appDetail).toEqual(previous)
|
||||
expect(queryClient.getQueryState(detailKey())?.isInvalidated).toBe(true)
|
||||
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
await act(async () => detail.resolve(updated))
|
||||
await waitFor(() => expect(useAppStore.getState().appDetail).toEqual(updated))
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['app-detail', 'app-1'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'starred'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'recent'] })
|
||||
await waitFor(() => expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture()))
|
||||
})
|
||||
|
||||
it('returns false after POST failure and allows retrying without refreshing failed saves', async () => {
|
||||
const { result } = renderActions()
|
||||
const previous = useAppStore.getState().appDetail
|
||||
mocks.updateAppSiteConfig.mockRejectedValueOnce(new Error('Save failed'))
|
||||
|
||||
expect(await result.current.saveSiteConfig(siteConfig)).toBe(false)
|
||||
expect(useAppStore.getState().appDetail).toEqual(previous)
|
||||
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
|
||||
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedUnsuccessfully', {
|
||||
type: 'error',
|
||||
})
|
||||
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a successful save successful when the background detail refresh fails', async () => {
|
||||
const { result } = renderActions()
|
||||
const previous = useAppStore.getState().appDetail
|
||||
const error = new Error('Refresh failed')
|
||||
mocks.fetchAppDetail.mockRejectedValueOnce(error)
|
||||
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
|
||||
await waitFor(() =>
|
||||
expect(errorLog).toHaveBeenCalledWith('Failed to refresh app detail:', error),
|
||||
)
|
||||
expect(useAppStore.getState().appDetail).toEqual(previous)
|
||||
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
|
||||
type: 'success',
|
||||
})
|
||||
errorLog.mockRestore()
|
||||
})
|
||||
|
||||
it('returns false without a request when management permission is denied', async () => {
|
||||
it('keeps site configuration behind Access Point management permission', async () => {
|
||||
const { result } = renderActions('app-1', false)
|
||||
expect(await result.current.saveSiteConfig(siteConfig)).toBe(false)
|
||||
expect(mocks.updateAppSiteConfig).not.toHaveBeenCalled()
|
||||
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
|
||||
expect(mocks.toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the separate token refresh callback contract', async () => {
|
||||
const { result } = renderActions()
|
||||
const updated = createAppDetailFixture({ id: 'app-1', name: 'Refreshed application' })
|
||||
mocks.fetchAppDetail.mockResolvedValueOnce(updated)
|
||||
await act(async () => result.current.refreshAppDetail())
|
||||
expect(useAppStore.getState().appDetail).toEqual(updated)
|
||||
const error = new Error('Token refresh failed')
|
||||
mocks.fetchAppDetail.mockRejectedValueOnce(error)
|
||||
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await expect(result.current.refreshAppDetail()).resolves.toBeUndefined()
|
||||
expect(errorLog).toHaveBeenCalledWith('Failed to refresh app detail:', error)
|
||||
errorLog.mockRestore()
|
||||
await act(async () => {
|
||||
await result.current.saveSiteConfig(siteConfig)
|
||||
})
|
||||
|
||||
expect(mocks.updateAppSiteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
@@ -29,37 +28,32 @@ vi.mock('@/app/notifications', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: {
|
||||
account: actual.consoleQuery.account,
|
||||
apps: {
|
||||
byAppId: {
|
||||
siteEnable: {
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
byAppId: {
|
||||
siteEnable: {
|
||||
post: {
|
||||
mutationOptions: (options = {}) => ({
|
||||
mutationFn: mocks.siteEnable,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
site: {
|
||||
accessTokenReset: {
|
||||
post: {
|
||||
mutationOptions: (options = {}) => ({
|
||||
mutationFn: mocks.siteEnable,
|
||||
mutationFn: mocks.resetSiteAccessToken,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
site: {
|
||||
accessTokenReset: {
|
||||
post: {
|
||||
mutationOptions: (options = {}) => ({
|
||||
mutationFn: mocks.resetSiteAccessToken,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -94,8 +88,27 @@ vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
|
||||
vi.mock('@/app/components/app/overview/customize', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/settings', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
default: ({
|
||||
hiddenInputs = [],
|
||||
isShow,
|
||||
}: {
|
||||
hiddenInputs?: Array<{ variable: string }>
|
||||
isShow: boolean
|
||||
}) =>
|
||||
isShow ? (
|
||||
<div role="dialog" aria-label="embed into site">
|
||||
{hiddenInputs.map((input) => input.variable).join(',')}
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
function createAppInfo(mode: AppModeEnum): AppDetailWithSite {
|
||||
@@ -138,7 +151,6 @@ function renderCard(
|
||||
appDetail: { ...createAppInfo(mode), ...appOverrides, access_mode: accessMode },
|
||||
})
|
||||
const queryClient = createTestQueryClient()
|
||||
seedAccountProfileQuery(queryClient)
|
||||
queryClient.setQueryData(['system-features'], {
|
||||
webapp_auth: { enabled: showAccessControl },
|
||||
})
|
||||
@@ -180,7 +192,7 @@ function StoreConnectedWebAppCard({
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
showAccessControl={showAccessControl}
|
||||
onRefreshApp={onRefreshApp}
|
||||
onSaveSiteConfig={vi.fn().mockResolvedValue(true)}
|
||||
onSaveSiteConfig={vi.fn().mockResolvedValue(undefined)}
|
||||
workflow={workflow}
|
||||
/>
|
||||
)
|
||||
@@ -391,9 +403,7 @@ describe('WebAppAccessPointCard', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'embed into site' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates site status through the generated contract', async () => {
|
||||
@@ -454,8 +464,7 @@ describe('WebAppAccessPointCard', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /hiddenInputs.title/ }))
|
||||
expect(screen.getByLabelText('Secret')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'embed into site' })).toHaveTextContent('secret')
|
||||
})
|
||||
|
||||
it('configures hidden workflow inputs before opening the Web App', async () => {
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.g
|
||||
import type { PublishedWorkflow } from '../shared/utils'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -223,14 +223,14 @@ export function MCPAccessPointCard({
|
||||
{t(($) => $['mcp.server.reGen'], { ns: 'tools' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -9,11 +9,11 @@ import type { PublishedWorkflow } from '../shared/utils'
|
||||
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -21,9 +21,9 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccessControl from '@/app/components/app/app-access-control'
|
||||
import { CustomizeDialog } from '@/app/components/app/overview/customize'
|
||||
import { EmbeddedDialog } from '@/app/components/app/overview/embedded'
|
||||
import { SettingsDialog } from '@/app/components/app/overview/settings'
|
||||
import CustomizeModal from '@/app/components/app/overview/customize'
|
||||
import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import SettingsModal from '@/app/components/app/overview/settings'
|
||||
import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { AccessPointCard } from '@/app/components/base/access-point/card'
|
||||
@@ -67,7 +67,7 @@ type WebAppAccessPointCardProps = {
|
||||
highlighted?: boolean
|
||||
showAccessControl: boolean
|
||||
onRefreshApp: () => Promise<void>
|
||||
onSaveSiteConfig: (params: AppSiteUpdatePayload) => Promise<boolean>
|
||||
onSaveSiteConfig: (params: AppSiteUpdatePayload) => Promise<void>
|
||||
workflow: PublishedWorkflow
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ export function WebAppAccessPointCard({
|
||||
'navigation',
|
||||
])
|
||||
const setAppDetail = useAppStore((state) => state.setAppDetail)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showEmbedded, setShowEmbedded] = useState(false)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
const [showAccess, setShowAccess] = useState(false)
|
||||
const [showRegenerate, setShowRegenerate] = useState(false)
|
||||
const [showWorkflowLaunch, setShowWorkflowLaunch] = useState(false)
|
||||
@@ -216,30 +219,36 @@ export function WebAppAccessPointCard({
|
||||
</Button>
|
||||
)}
|
||||
{supportsEmbedded && (
|
||||
<EmbeddedDialog
|
||||
siteInfo={site ?? undefined}
|
||||
appBaseUrl={site?.app_base_url}
|
||||
accessToken={site?.access_token ?? undefined}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!actionsAvailable || !canManageAccessPoint}
|
||||
/>
|
||||
onClick={() => setShowEmbedded(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-window-line size-4" />
|
||||
{t(($) => $['studio.accessPoint.embedIntoSite'], { ns: 'deployments' })}
|
||||
</Button>
|
||||
)}
|
||||
<CustomizeDialog
|
||||
appId={appInfo.id}
|
||||
api_base_url={appInfo.api_base_url}
|
||||
mode={appInfo.mode}
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!actionsAvailable || !canManageAccessPoint}
|
||||
/>
|
||||
<SettingsDialog
|
||||
key={appInfo.id}
|
||||
isChat={
|
||||
appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW
|
||||
}
|
||||
canDeploy={canDeploy}
|
||||
appInfo={site ? { id: appInfo.id, mode: appInfo.mode, site } : undefined}
|
||||
onClick={() => setShowCustomize(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
{t(($) => $['overview.appInfo.customize.entry'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={siteAvailability !== 'available' || !canManageAccessPoint}
|
||||
onSave={onSaveSiteConfig}
|
||||
/>
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
|
||||
{t(($) => $['settings.settings'], { ns: 'navigation' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -281,6 +290,33 @@ export function WebAppAccessPointCard({
|
||||
))}
|
||||
</AccessPointCard>
|
||||
|
||||
{site && (
|
||||
<SettingsModal
|
||||
isChat={appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW}
|
||||
canDeploy={canDeploy}
|
||||
appInfo={{ id: appInfo.id, mode: appInfo.mode, site }}
|
||||
isShow={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onSave={onSaveSiteConfig}
|
||||
/>
|
||||
)}
|
||||
{supportsEmbedded && site?.access_token && (
|
||||
<EmbeddedModal
|
||||
siteInfo={site}
|
||||
isShow={showEmbedded}
|
||||
onClose={() => setShowEmbedded(false)}
|
||||
appBaseUrl={site.app_base_url}
|
||||
accessToken={site.access_token}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
)}
|
||||
<CustomizeModal
|
||||
isShow={showCustomize}
|
||||
onClose={() => setShowCustomize(false)}
|
||||
appId={appInfo.id}
|
||||
api_base_url={appInfo.api_base_url}
|
||||
mode={appInfo.mode}
|
||||
/>
|
||||
{showAccess && (
|
||||
<AccessControl
|
||||
app={appInfo}
|
||||
@@ -308,7 +344,7 @@ export function WebAppAccessPointCard({
|
||||
{t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -318,7 +354,7 @@ export function WebAppAccessPointCard({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CustomizeDialog } from '@/app/components/app/overview/customize'
|
||||
import { SettingsDialog } from '@/app/components/app/overview/settings'
|
||||
import CustomizeModal from '@/app/components/app/overview/customize'
|
||||
import SettingsModal from '@/app/components/app/overview/settings'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { AccessPointCard } from '@/app/components/base/access-point/card'
|
||||
import { AccessPointUrl } from '@/app/components/base/access-point/url'
|
||||
@@ -63,6 +64,8 @@ export function EnvironmentWebAppCard({
|
||||
const appInfo = useAppStore((state) => state.appDetail)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const actions = useAccessPointActions(appId, canManageAccessPoint)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
const [showAccess, setShowAccess] = useState(false)
|
||||
const [showRegenerate, setShowRegenerate] = useState(false)
|
||||
const params = {
|
||||
@@ -186,24 +189,26 @@ export function EnvironmentWebAppCard({
|
||||
onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined}
|
||||
actions={
|
||||
<>
|
||||
<CustomizeDialog
|
||||
appId={appId}
|
||||
api_base_url={apiQuery.data?.base_url ?? ''}
|
||||
mode={appInfo?.mode}
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!actionsAvailable || !apiQuery.isSuccess || !canManageAccessPoint}
|
||||
/>
|
||||
<SettingsDialog
|
||||
key={appId}
|
||||
isChat={false}
|
||||
canDeploy
|
||||
appInfo={
|
||||
appInfo?.site
|
||||
? { id: appInfo.id, mode: appInfo.mode, site: appInfo.site }
|
||||
: undefined
|
||||
}
|
||||
disabled={!siteQuery.isSuccess || !canManageAccessPoint}
|
||||
onSave={actions.saveSiteConfig}
|
||||
/>
|
||||
onClick={() => setShowCustomize(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
{t(($) => $['overview.appInfo.customize.entry'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!appInfo?.site || !siteQuery.isSuccess || !canManageAccessPoint}
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
|
||||
{t(($) => $['settings.settings'], { ns: 'navigation' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -242,6 +247,23 @@ export function EnvironmentWebAppCard({
|
||||
))}
|
||||
</AccessPointCard>
|
||||
|
||||
{appInfo?.site && (
|
||||
<SettingsModal
|
||||
isChat={false}
|
||||
canDeploy
|
||||
appInfo={{ id: appInfo.id, mode: appInfo.mode, site: appInfo.site }}
|
||||
isShow={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onSave={actions.saveSiteConfig}
|
||||
/>
|
||||
)}
|
||||
<CustomizeModal
|
||||
isShow={showCustomize}
|
||||
onClose={() => setShowCustomize(false)}
|
||||
appId={appId}
|
||||
api_base_url={apiQuery.data?.base_url ?? ''}
|
||||
mode={appInfo?.mode}
|
||||
/>
|
||||
{showAccess && (
|
||||
<EnvironmentAccessControl
|
||||
appId={appId}
|
||||
@@ -264,7 +286,7 @@ export function EnvironmentWebAppCard({
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -274,7 +296,7 @@ export function EnvironmentWebAppCard({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { consoleClient, consoleQuery } from '@/service/console'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
|
||||
export function useAccessPointActions(appId: string, canManageAccessPoint: boolean) {
|
||||
const { t } = useTranslation(['common'])
|
||||
@@ -21,14 +23,31 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
|
||||
}
|
||||
}, [appId, setAppDetail])
|
||||
|
||||
const handleResult = useCallback(
|
||||
(error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
|
||||
const type = error ? 'error' : 'success'
|
||||
const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully')
|
||||
|
||||
if (!error) {
|
||||
void refreshAppDetail()
|
||||
}
|
||||
|
||||
toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, {
|
||||
type,
|
||||
})
|
||||
},
|
||||
[refreshAppDetail, t],
|
||||
)
|
||||
const saveSiteConfig = useCallback(
|
||||
async (params: AppSiteUpdatePayload): Promise<boolean> => {
|
||||
if (!canManageAccessPoint) return false
|
||||
try {
|
||||
await consoleClient.apps.byAppId.site.post({
|
||||
async (params: AppSiteUpdatePayload) => {
|
||||
if (!canManageAccessPoint) return
|
||||
const [error] = await asyncRunSafe(
|
||||
consoleClient.apps.byAppId.site.post({
|
||||
params: { app_id: appId },
|
||||
body: params,
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (!error) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.apps.byAppId.get.queryKey({
|
||||
input: { params: { app_id: appId } },
|
||||
@@ -37,24 +56,14 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
|
||||
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
|
||||
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
|
||||
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
|
||||
void refreshAppDetail()
|
||||
toast(
|
||||
t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }),
|
||||
{ type: 'success' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
toast(
|
||||
t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }),
|
||||
{ type: 'error' },
|
||||
)
|
||||
return false
|
||||
}
|
||||
handleResult(error)
|
||||
},
|
||||
[appId, canManageAccessPoint, queryClient, refreshAppDetail, t],
|
||||
[appId, canManageAccessPoint, handleResult, queryClient],
|
||||
)
|
||||
|
||||
return {
|
||||
handleResult,
|
||||
refreshAppDetail,
|
||||
saveSiteConfig,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -69,14 +69,14 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
{t(($) => $['list.delete.title'], { ns: 'appAnnotation' })}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={isDeleting} onClick={handleBatchDelete}>
|
||||
{t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import * as React from 'react'
|
||||
@@ -33,14 +33,14 @@ const ClearAllAnnotationsConfirmModal: FC<Props> = ({ isShow, onHide, onConfirm
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirm}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import {
|
||||
@@ -142,7 +142,7 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
{t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -156,7 +156,7 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import * as React from 'react'
|
||||
@@ -32,14 +32,14 @@ const RemoveAnnotationConfirmModal: FC<Props> = ({ isShow, onHide, onRemove }) =
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton tone="destructive" onClick={onRemove}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { FC } from 'react'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../type'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -273,7 +273,7 @@ const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, o
|
||||
{t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -287,7 +287,7 @@ const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, o
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { ConfigurationPublishConfig } from '@/app/components/app/configurat
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { produce } from 'immer'
|
||||
@@ -119,14 +119,14 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
{t(($) => $['resetConfig.message'], { ns: 'appDebug' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleConfirm}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,10 @@ vi.mock('@/app/components/app/configuration/debug', () => ({
|
||||
default: () => <div data-testid="debug-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => ({
|
||||
default: () => <div data-testid="agent-setting-button" />,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
|
||||
() => ({
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -411,7 +411,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
{t(($) => $['feature.dataSet.queryVariable.deleteContextVarTip'], { ns: 'appDebug' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -423,7 +423,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
import AgentSettingButton from '../agent-setting-button'
|
||||
|
||||
let latestAgentSettingProps: any
|
||||
vi.mock('../agent/agent-setting', () => ({
|
||||
AgentSetting: (props: any) => {
|
||||
latestAgentSettingProps = props
|
||||
return (
|
||||
<div data-testid="agent-setting">
|
||||
<button onClick={() => props.onSave({ ...props.payload, max_iteration: 9 })}>
|
||||
save-agent
|
||||
</button>
|
||||
<button onClick={props.onCancel}>cancel-agent</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const createAgentConfig = (overrides: Partial<AgentConfig> = {}): AgentConfig => ({
|
||||
enabled: true,
|
||||
strategy: AgentStrategy.react,
|
||||
max_iteration: 3,
|
||||
tools: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const setup = (overrides: Partial<React.ComponentProps<typeof AgentSettingButton>> = {}) => {
|
||||
const props: React.ComponentProps<typeof AgentSettingButton> = {
|
||||
isFunctionCall: false,
|
||||
isChatModel: true,
|
||||
onAgentSettingChange: vi.fn(),
|
||||
agentConfig: createAgentConfig(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AgentSettingButton {...props} />)
|
||||
return { props, user }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestAgentSettingProps = undefined
|
||||
})
|
||||
|
||||
describe('AgentSettingButton', () => {
|
||||
it('should render button label from translation key', () => {
|
||||
setup()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open AgentSetting with the provided configuration when clicked', async () => {
|
||||
const { user, props } = setup({ isFunctionCall: true, isChatModel: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
|
||||
|
||||
expect(screen.getByTestId('agent-setting')).toBeInTheDocument()
|
||||
expect(latestAgentSettingProps.isFunctionCall).toBe(true)
|
||||
expect(latestAgentSettingProps.isChatModel).toBe(false)
|
||||
expect(latestAgentSettingProps.payload).toEqual(props.agentConfig)
|
||||
})
|
||||
|
||||
it('should call onAgentSettingChange and close when AgentSetting saves', async () => {
|
||||
const { user, props } = setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
|
||||
await user.click(screen.getByText('save-agent'))
|
||||
|
||||
expect(props.onAgentSettingChange).toHaveBeenCalledTimes(1)
|
||||
expect(props.onAgentSettingChange).toHaveBeenCalledWith({
|
||||
...props.agentConfig,
|
||||
max_iteration: 9,
|
||||
})
|
||||
expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close AgentSetting without saving when cancel is triggered', async () => {
|
||||
const { user, props } = setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
|
||||
await user.click(screen.getByText('cancel-agent'))
|
||||
|
||||
expect(props.onAgentSettingChange).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentSetting } from './agent/agent-setting'
|
||||
|
||||
type Props = Readonly<{
|
||||
isFunctionCall: boolean
|
||||
isChatModel: boolean
|
||||
agentConfig?: AgentConfig
|
||||
disabled?: boolean
|
||||
onAgentSettingChange: (payload: AgentConfig) => void
|
||||
}>
|
||||
|
||||
const AgentSettingButton: FC<Props> = ({
|
||||
onAgentSettingChange,
|
||||
isFunctionCall,
|
||||
isChatModel,
|
||||
agentConfig,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['appDebug'])
|
||||
const [isShowAgentSetting, setIsShowAgentSetting] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsShowAgentSetting(true)}
|
||||
className="mr-2 shrink-0"
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="i-ri-settings-2-line size-4 text-text-tertiary" />
|
||||
{t(($) => $['agent.setting.name'], { ns: 'appDebug' })}
|
||||
</Button>
|
||||
{isShowAgentSetting && (
|
||||
<AgentSetting
|
||||
isFunctionCall={isFunctionCall}
|
||||
payload={agentConfig as AgentConfig}
|
||||
isChatModel={isChatModel}
|
||||
onSave={(payloadNew) => {
|
||||
onAgentSettingChange(payloadNew)
|
||||
setIsShowAgentSetting(false)
|
||||
}}
|
||||
onCancel={() => setIsShowAgentSetting(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(AgentSettingButton)
|
||||
@@ -1,96 +1,127 @@
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { MAX_ITERATIONS_NUM } from '@/config'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
import { AgentSettingDialog } from '../index'
|
||||
import { AgentSetting } from '../index'
|
||||
|
||||
const basePayload: AgentConfig = {
|
||||
const basePayload = {
|
||||
enabled: true,
|
||||
strategy: AgentStrategy.react,
|
||||
strategy: 'react',
|
||||
max_iteration: 5,
|
||||
tools: [],
|
||||
}
|
||||
|
||||
function SettingsFixture({ onSave }: { onSave: (payload: AgentConfig) => void }) {
|
||||
const [payload, setPayload] = useState(basePayload)
|
||||
return (
|
||||
<AgentSettingDialog
|
||||
const renderModal = (props?: Partial<React.ComponentProps<typeof AgentSetting>>) => {
|
||||
const onCancel = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const utils = render(
|
||||
<AgentSetting
|
||||
isChatModel
|
||||
payload={payload}
|
||||
payload={basePayload as AgentConfig}
|
||||
isFunctionCall={false}
|
||||
onSave={(value) => {
|
||||
setPayload(value)
|
||||
onSave(value)
|
||||
}}
|
||||
/>
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { ...utils, onCancel, onSave }
|
||||
}
|
||||
|
||||
const settingName = 'appDebug.agent.setting.name'
|
||||
describe('AgentSetting', () => {
|
||||
it('should render agent mode description and default prompt section when not function call', () => {
|
||||
renderModal()
|
||||
|
||||
describe('AgentSettingDialog', () => {
|
||||
it('discards cancelled edits, submits current iterations, and reopens the saved configuration', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
render(<SettingsFixture onSave={onSave} />)
|
||||
const trigger = screen.getByRole('button', { name: settingName })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('appDebug.agent.agentMode')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.builtInPromptTitle')).toBeInTheDocument()
|
||||
await user.clear(screen.getByRole('spinbutton'))
|
||||
await user.type(screen.getByRole('spinbutton'), '7')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(5)
|
||||
await user.clear(screen.getByRole('spinbutton'))
|
||||
await user.type(screen.getByRole('spinbutton'), '6{Enter}')
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(6)
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('clamps iterations and saves the minimum when the number input is cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
render(<AgentSettingDialog isChatModel payload={basePayload} isFunctionCall onSave={onSave} />)
|
||||
await user.click(screen.getByRole('button', { name: settingName }))
|
||||
it('should display function call mode when isFunctionCall true', () => {
|
||||
renderModal({ isFunctionCall: true })
|
||||
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
|
||||
expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
await user.clear(input)
|
||||
await user.type(input, '999')
|
||||
expect(input).toHaveValue(MAX_ITERATIONS_NUM)
|
||||
await user.clear(input)
|
||||
await user.type(input, '0')
|
||||
expect(input).toHaveValue(1)
|
||||
await user.clear(input)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 1 }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('keeps readonly settings unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<AgentSettingDialog
|
||||
isChatModel
|
||||
payload={basePayload}
|
||||
isFunctionCall
|
||||
disabled
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: settingName })
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
it('should update iteration via slider and number input', () => {
|
||||
renderModal()
|
||||
const slider = screen.getByRole('slider')
|
||||
const numberInput = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(slider, { target: { value: '7' } })
|
||||
expect(screen.getAllByDisplayValue('7')).toHaveLength(2)
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '2' } })
|
||||
expect(screen.getAllByDisplayValue('2')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should clamp iteration value within min/max range', () => {
|
||||
renderModal()
|
||||
|
||||
const numberInput = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '0' } })
|
||||
expect(screen.getAllByDisplayValue('1')).toHaveLength(2)
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '999' } })
|
||||
expect(screen.getAllByDisplayValue(String(MAX_ITERATIONS_NUM))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button clicked', () => {
|
||||
const { onCancel } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape is pressed', () => {
|
||||
const { onCancel } = renderModal()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when the backdrop is clicked', () => {
|
||||
const { onCancel } = renderModal()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.pointerDown(document.body, { target: document.body })
|
||||
fireEvent.mouseDown(document.body, { target: document.body })
|
||||
fireEvent.click(document.body, { target: document.body })
|
||||
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close button clicked', () => {
|
||||
const { onCancel } = renderModal()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSave with updated payload', async () => {
|
||||
const { onSave } = renderModal()
|
||||
const numberInput = screen.getByRole('spinbutton')
|
||||
fireEvent.change(numberInput, { target: { value: '6' } })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 }))
|
||||
})
|
||||
|
||||
it('should save the minimum iteration when the number input is empty', async () => {
|
||||
const { onSave } = renderModal()
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveValue('1')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 1 }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
'use client'
|
||||
import type { DialogActions } from '@langgenius/dify-ui/dialog'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import {
|
||||
@@ -19,7 +12,7 @@ import {
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
} from '@langgenius/dify-ui/slider'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config'
|
||||
import ItemPanel from './item-panel'
|
||||
@@ -28,41 +21,13 @@ type Props = Readonly<{
|
||||
isChatModel: boolean
|
||||
payload: AgentConfig
|
||||
isFunctionCall: boolean
|
||||
disabled?: boolean
|
||||
onCancel: () => void
|
||||
onSave: (payload: AgentConfig) => void
|
||||
}>
|
||||
|
||||
const maxIterationsMin = 1
|
||||
|
||||
export function AgentSettingDialog({ disabled, onSave, ...props }: Props) {
|
||||
const { t } = useTranslation(['appDebug'])
|
||||
const actionsRef = useRef<DialogActions>(null)
|
||||
|
||||
return (
|
||||
<Dialog actionsRef={actionsRef}>
|
||||
<DialogTrigger render={<Button className="mr-2 shrink-0" disabled={disabled} />}>
|
||||
<span aria-hidden className="i-ri-settings-2-line size-4 text-text-tertiary" />
|
||||
{t(($) => $['agent.setting.name'])}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="top-2 right-2 bottom-2 left-auto flex h-auto max-h-none w-160 max-w-[calc(100vw-1rem)] translate-x-0 translate-y-0 flex-col overflow-hidden rounded-xl p-0">
|
||||
<AgentSettingsForm
|
||||
{...props}
|
||||
onSave={(payload) => {
|
||||
onSave(payload)
|
||||
actionsRef.current?.close()
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSettingsForm({
|
||||
isChatModel,
|
||||
payload,
|
||||
isFunctionCall,
|
||||
onSave,
|
||||
}: Omit<Props, 'disabled'>) {
|
||||
export function AgentSetting({ isChatModel, payload, isFunctionCall, onCancel, onSave }: Props) {
|
||||
const { t } = useTranslation(['appDebug', 'common', 'tools'])
|
||||
const [tempPayload, setTempPayload] = useState(payload)
|
||||
const maximumIterationsLabel = t(($) => $['agent.setting.maximumIterations.name'], {
|
||||
@@ -80,137 +45,141 @@ function AgentSettingsForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleSave()
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCancel()
|
||||
}}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6">
|
||||
<DialogTitle className="text-base leading-6 font-semibold text-text-primary">
|
||||
{t(($) => $['agent.setting.name'], { ns: 'appDebug' })}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
size="sm"
|
||||
className="static z-auto size-6 shrink-0 rounded-2xl"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div
|
||||
className="grow overflow-y-auto border-b border-divider-regular p-6 pt-5 pb-17"
|
||||
style={{
|
||||
borderBottom: 'rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Agent Mode */}
|
||||
<ItemPanel
|
||||
className="mb-4"
|
||||
icon={
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-communication-cute-robot size-4 text-indigo-600"
|
||||
/>
|
||||
}
|
||||
name={t(($) => $['agent.agentMode'], { ns: 'appDebug' })}
|
||||
description={t(($) => $['agent.agentModeDes'], { ns: 'appDebug' })}
|
||||
<DialogContent className="top-2 right-2 bottom-2 left-auto flex h-auto max-h-none w-160 max-w-[calc(100vw-1rem)] translate-x-0 translate-y-0 flex-col overflow-hidden rounded-xl p-0">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6">
|
||||
<DialogTitle className="text-base leading-6 font-semibold text-text-primary">
|
||||
{t(($) => $['agent.setting.name'], { ns: 'appDebug' })}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
size="sm"
|
||||
className="static z-auto size-6 shrink-0 rounded-2xl"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div
|
||||
className="grow overflow-y-auto border-b border-divider-regular p-6 pt-5 pb-17"
|
||||
style={{
|
||||
borderBottom: 'rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[13px] leading-4.5 font-medium text-text-primary">
|
||||
{isFunctionCall
|
||||
? t(($) => $['agent.agentModeType.functionCall'], { ns: 'appDebug' })
|
||||
: t(($) => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })}
|
||||
</div>
|
||||
</ItemPanel>
|
||||
|
||||
<ItemPanel
|
||||
className="mb-4"
|
||||
icon={
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-education-unblur h-4 w-4 text-[#FB6514]"
|
||||
/>
|
||||
}
|
||||
name={maximumIterationsLabel}
|
||||
description={t(($) => $['agent.setting.maximumIterations.description'], {
|
||||
ns: 'appDebug',
|
||||
})}
|
||||
>
|
||||
<Fieldset className="flex items-center">
|
||||
<FieldsetLegend className="sr-only">{maximumIterationsLabel}</FieldsetLegend>
|
||||
<Slider
|
||||
className="mr-3 w-39"
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM}
|
||||
value={sliderValue}
|
||||
onValueChange={(value) => {
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SliderLabel className="sr-only">{maximumIterationsLabel}</SliderLabel>
|
||||
<SliderControl>
|
||||
<SliderTrack>
|
||||
<SliderIndicator />
|
||||
<SliderThumb />
|
||||
</SliderTrack>
|
||||
</SliderControl>
|
||||
</Slider>
|
||||
|
||||
<input
|
||||
aria-label={maximumIterationsLabel}
|
||||
type="number"
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM}
|
||||
step={1}
|
||||
className="block h-7 w-11 rounded-lg border-0 bg-components-input-bg-normal px-1.5 pl-1 leading-7 text-text-primary placeholder:text-text-tertiary focus:inset-ring-1 focus:inset-ring-primary-600"
|
||||
value={Number.isFinite(tempPayload.max_iteration) ? tempPayload.max_iteration : ''}
|
||||
onChange={(e) => {
|
||||
let value = Number.parseInt(e.target.value, 10)
|
||||
if (value < maxIterationsMin) value = maxIterationsMin
|
||||
|
||||
if (value > MAX_ITERATIONS_NUM) value = MAX_ITERATIONS_NUM
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Fieldset>
|
||||
</ItemPanel>
|
||||
|
||||
{!isFunctionCall && (
|
||||
<div className="rounded-xl bg-background-section-burn py-2 shadow-xs">
|
||||
<div className="flex h-8 items-center px-4 text-sm/6 font-semibold text-text-secondary">
|
||||
{t(($) => $.builtInPromptTitle, { ns: 'tools' })}
|
||||
{/* Agent Mode */}
|
||||
<ItemPanel
|
||||
className="mb-4"
|
||||
icon={
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-communication-cute-robot size-4 text-indigo-600"
|
||||
/>
|
||||
}
|
||||
name={t(($) => $['agent.agentMode'], { ns: 'appDebug' })}
|
||||
description={t(($) => $['agent.agentModeDes'], { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="text-[13px] leading-4.5 font-medium text-text-primary">
|
||||
{isFunctionCall
|
||||
? t(($) => $['agent.agentModeType.functionCall'], { ns: 'appDebug' })
|
||||
: t(($) => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="h-99 overflow-y-auto px-4 text-sm leading-5 font-normal whitespace-pre-line text-text-secondary">
|
||||
{isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion}
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs leading-4.5 font-medium text-text-tertiary">
|
||||
{(isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion).length}
|
||||
</ItemPanel>
|
||||
|
||||
<ItemPanel
|
||||
className="mb-4"
|
||||
icon={
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-education-unblur h-4 w-4 text-[#FB6514]"
|
||||
/>
|
||||
}
|
||||
name={maximumIterationsLabel}
|
||||
description={t(($) => $['agent.setting.maximumIterations.description'], {
|
||||
ns: 'appDebug',
|
||||
})}
|
||||
>
|
||||
<Fieldset className="flex items-center">
|
||||
<FieldsetLegend className="sr-only">{maximumIterationsLabel}</FieldsetLegend>
|
||||
<Slider
|
||||
className="mr-3 w-39"
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM}
|
||||
value={sliderValue}
|
||||
onValueChange={(value) => {
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SliderLabel className="sr-only">{maximumIterationsLabel}</SliderLabel>
|
||||
<SliderControl>
|
||||
<SliderTrack>
|
||||
<SliderIndicator />
|
||||
<SliderThumb />
|
||||
</SliderTrack>
|
||||
</SliderControl>
|
||||
</Slider>
|
||||
|
||||
<input
|
||||
aria-label={maximumIterationsLabel}
|
||||
type="number"
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM}
|
||||
step={1}
|
||||
className="block h-7 w-11 rounded-lg border-0 bg-components-input-bg-normal px-1.5 pl-1 leading-7 text-text-primary placeholder:text-text-tertiary focus:inset-ring-1 focus:inset-ring-primary-600"
|
||||
value={tempPayload.max_iteration}
|
||||
onChange={(e) => {
|
||||
let value = Number.parseInt(e.target.value, 10)
|
||||
if (value < maxIterationsMin) value = maxIterationsMin
|
||||
|
||||
if (value > MAX_ITERATIONS_NUM) value = MAX_ITERATIONS_NUM
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Fieldset>
|
||||
</ItemPanel>
|
||||
|
||||
{!isFunctionCall && (
|
||||
<div className="rounded-xl bg-background-section-burn py-2 shadow-xs">
|
||||
<div className="flex h-8 items-center px-4 text-sm/6 font-semibold text-text-secondary">
|
||||
{t(($) => $.builtInPromptTitle, { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="h-99 overflow-y-auto px-4 text-sm leading-5 font-normal whitespace-pre-line text-text-secondary">
|
||||
{isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion}
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs leading-4.5 font-medium text-text-tertiary">
|
||||
{
|
||||
(isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion)
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4">
|
||||
<DialogClose render={<Button className="mr-2" />}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary">
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4">
|
||||
<Button type="button" onClick={onCancel} className="mr-2">
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={handleSave}>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import type { GenRes } from '@/service/debug'
|
||||
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -416,7 +416,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
{t(($) => $['generate.overwriteMessage'], { ns: 'appGeneration' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -428,7 +428,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { GenRes } from '@/service/debug'
|
||||
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -288,7 +288,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
|
||||
{t(($) => $['codegen.overwriteConfirmMessage'], { ns: 'appGeneration' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -300,7 +300,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { InstallBundleCompleteCallback } from '@/app/components/plugins/ins
|
||||
import { CodeBracketIcon } from '@heroicons/react/20/solid'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -30,7 +30,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import Config from '@/app/components/app/configuration/config'
|
||||
import EditHistoryModal from '@/app/components/app/configuration/config-prompt/conversation-history/edit-modal'
|
||||
import { AgentSettingDialog } from '@/app/components/app/configuration/config/agent/agent-setting'
|
||||
import AgentSettingButton from '@/app/components/app/configuration/config/agent-setting-button'
|
||||
import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
|
||||
import Debug from '@/app/components/app/configuration/debug'
|
||||
import { FeaturesProvider } from '@/app/components/base/features'
|
||||
@@ -129,12 +129,12 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isAgent && (
|
||||
<AgentSettingDialog
|
||||
<AgentSettingButton
|
||||
isChatModel={contextValue.modelModeType === ModelModeType.chat}
|
||||
payload={modelConfig.agentConfig}
|
||||
agentConfig={modelConfig.agentConfig}
|
||||
isFunctionCall={contextValue.isFunctionCall}
|
||||
disabled={contextValue.readonly}
|
||||
onSave={onAgentSettingChange}
|
||||
onAgentSettingChange={onAgentSettingChange}
|
||||
/>
|
||||
)}
|
||||
{!debugWithMultipleModel && (
|
||||
@@ -208,7 +208,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
{t(($) => $['trailUseGPT4Info.description'], { ns: 'appDebug' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton tone="default">
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -219,7 +219,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ vi.mock('../debug-with-single-model', () => ({
|
||||
),
|
||||
}))
|
||||
vi.mock('@/app/components/base/chat/chat/answer', async () => {
|
||||
const { Operation } = await import('@/app/components/base/chat/chat/answer/operation')
|
||||
const { default: Operation } = await import('@/app/components/base/chat/chat/answer/operation')
|
||||
return {
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<div className="group">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '#i18n'
|
||||
import { toast } from '@/app/components/app/configuration/toast'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
@@ -44,6 +45,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
const docLink = useDocLink()
|
||||
const locale = useLocale()
|
||||
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')
|
||||
|
||||
const providers = buildProviders({
|
||||
@@ -154,26 +156,20 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
t(($) => $['feature.tools.modal.name.placeholder'], { ns: 'appDebug' }) || ''
|
||||
}
|
||||
/>
|
||||
<IconPickerDialog
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['iconPicker.title'], { ns: 'app' })}
|
||||
size="large"
|
||||
iconClassName="h-9! w-9! rounded-lg border-[0.5px] border-components-panel-border"
|
||||
enableImageUpload={false}
|
||||
value={
|
||||
localeData.icon
|
||||
? {
|
||||
type: 'emoji',
|
||||
icon: localeData.icon,
|
||||
background: localeData.icon_background,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onConfirm={(value) => {
|
||||
if (value.type !== 'emoji') return
|
||||
const { icon, background: icon_background } = value
|
||||
handleValueChange({ icon, icon_background })
|
||||
}}
|
||||
/>
|
||||
className="shrink-0 cursor-pointer rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setShowEmojiPicker(true)}
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
size="large"
|
||||
className="h-9! w-9! rounded-lg border-[0.5px] border-components-panel-border"
|
||||
icon={localeData.icon}
|
||||
background={localeData.icon_background}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
@@ -227,6 +223,25 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<IconPickerDialog
|
||||
enableImageUpload={false}
|
||||
defaultValue={
|
||||
localeData.icon
|
||||
? {
|
||||
type: 'emoji',
|
||||
icon: localeData.icon,
|
||||
background: localeData.icon_background,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
open={showEmojiPicker}
|
||||
onOpenChange={setShowEmojiPicker}
|
||||
onConfirm={(value) => {
|
||||
if (value.type !== 'emoji') return
|
||||
const { icon, background: icon_background } = value
|
||||
handleValueChange({ icon, icon_background })
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppDetail } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import { HotkeyManager } from '@tanstack/react-hotkeys'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
@@ -125,7 +124,6 @@ function render(ui: ReactElement) {
|
||||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
HotkeyManager.resetInstance()
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
|
||||
appQuota = { size: 0, limit: 1 }
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
@@ -244,91 +242,45 @@ describe('CreateAppModal', () => {
|
||||
expect(onCreateFromTemplate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ platform: 'Win32', modifier: { ctrlKey: true } },
|
||||
{ platform: 'MacIntel', modifier: { metaKey: true } },
|
||||
])(
|
||||
'creates a beginner chat app from its scoped $platform shortcut and selected icon style',
|
||||
async ({ platform, modifier }) => {
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform)
|
||||
mockCreateApp.mockResolvedValue(
|
||||
createAppDetailFixture({ id: 'chat-app', mode: AppModeEnum.CHAT }),
|
||||
)
|
||||
renderModal()
|
||||
it('creates a beginner chat app with the keyboard shortcut and selected icon style', async () => {
|
||||
mockCreateApp.mockResolvedValue(
|
||||
createAppDetailFixture({ id: 'chat-app', mode: AppModeEnum.CHAT }),
|
||||
)
|
||||
renderModal()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.forBeginners'))
|
||||
fireEvent.click(screen.getByText('app.types.chatbot'))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
|
||||
target: { value: 'Keyboard App' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
const pickerSearch = screen.getByPlaceholderText('app.iconPicker.search')
|
||||
fireEvent.keyDown(pickerSearch, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
fireEvent.keyUp(pickerSearch, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
expect(mockCreateApp).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'app.iconPicker.color.green' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), {
|
||||
target: { value: 'Created from shortcut' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('app.newApp.forBeginners'))
|
||||
fireEvent.click(screen.getByText('app.types.chatbot'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'app.iconPicker.color.green' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
|
||||
target: { value: 'Keyboard App' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), {
|
||||
target: { value: 'Created from shortcut' },
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
fireEvent.keyUp(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', ctrlKey: true })
|
||||
fireEvent.keyUp(nameInput, { key: 'Enter', code: 'Enter', ctrlKey: true })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateApp).toHaveBeenCalledWith({
|
||||
name: 'Keyboard App',
|
||||
description: 'Created from shortcut',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#F3FEE7',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockCreateApp).toHaveBeenCalledWith({
|
||||
name: 'Keyboard App',
|
||||
description: 'Created from shortcut',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#F3FEE7',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ platform: 'Win32', modifier: { ctrlKey: true } },
|
||||
{ platform: 'MacIntel', modifier: { metaKey: true } },
|
||||
])(
|
||||
're-registers the $platform shortcut after the outer dialog remounts its content',
|
||||
async ({ platform, modifier }) => {
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform)
|
||||
mockCreateApp.mockResolvedValue(
|
||||
createAppDetailFixture({ id: 'reopened-app', mode: AppModeEnum.ADVANCED_CHAT }),
|
||||
)
|
||||
const onClose = vi.fn()
|
||||
const modal = (show: boolean) => (
|
||||
<CreateAppModal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
onCreateFromTemplate={vi.fn()}
|
||||
defaultAppMode={AppModeEnum.ADVANCED_CHAT}
|
||||
/>
|
||||
)
|
||||
const { rerender } = render(modal(true))
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
|
||||
rerender(modal(false))
|
||||
await waitFor(() => expect(picker).not.toBeInTheDocument())
|
||||
rerender(modal(true))
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'Reopened App' } })
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
fireEvent.keyUp(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
await waitFor(() => expect(mockCreateApp).toHaveBeenCalledOnce())
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Reopened App' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps creation unavailable from both the button and shortcut without a name', () => {
|
||||
renderModal()
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { toast } from '@/app/notifications'
|
||||
@@ -59,6 +60,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
})
|
||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() =>
|
||||
@@ -176,7 +178,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
},
|
||||
{
|
||||
target: contentRef,
|
||||
enabled: !createDisabled && !isCreating,
|
||||
enabled: !createDisabled && !isCreating && !showIconPicker,
|
||||
ignoreInputs: false,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
@@ -322,10 +324,26 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
placeholder={t(($) => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['iconPicker.title'], { ns: 'app' })}
|
||||
className="shrink-0 cursor-pointer rounded-2xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setShowIconPicker(true)}
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
|
||||
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
size="xxl"
|
||||
className="rounded-2xl"
|
||||
/>
|
||||
</button>
|
||||
<IconPickerDialog
|
||||
value={appIcon}
|
||||
size="xxl"
|
||||
iconClassName="rounded-2xl"
|
||||
open={showIconPicker}
|
||||
defaultValue={appIcon}
|
||||
onOpenChange={setShowIconPicker}
|
||||
onConfirm={(payload) => {
|
||||
setAppIcon(payload)
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -58,7 +58,7 @@ function DSLConfirmModal({
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary" disabled={confirmLoading}>
|
||||
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -69,7 +69,7 @@ function DSLConfirmModal({
|
||||
>
|
||||
{t(($) => $['newApp.Confirm'], { ns: 'app' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -52,14 +52,14 @@ export function UndeployConfirmDialog({
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary" className="min-w-20" disabled={isPending}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton className="min-w-20" loading={isPending} onClick={onConfirm}>
|
||||
{t(($) => $['deployTab.confirmUndeploy'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -7,11 +7,8 @@ import {
|
||||
renderWithConsoleQuery,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import { mockEmojiData } from '@/test/emoji-picker'
|
||||
import DuplicateAppModal from '../index'
|
||||
|
||||
mockEmojiData()
|
||||
|
||||
const { mockAppQuota, toastErrorMock } = vi.hoisted(() => ({
|
||||
mockAppQuota: { size: 0, limit: 1 },
|
||||
toastErrorMock: vi.fn(),
|
||||
@@ -23,10 +20,56 @@ vi.mock('@/app/notifications', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <span>app-icon</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
default: () => <div>apps-full</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icon-picker', () => ({
|
||||
IconPickerDialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (payload: { type: 'emoji'; icon: string; background: string }) => void
|
||||
}) => {
|
||||
if (!open) return null
|
||||
let selectedBackground = '#FFEAD5'
|
||||
return (
|
||||
<div>
|
||||
<input placeholder="Search emojis..." />
|
||||
<button type="button" onClick={() => {}}>
|
||||
<span>😀</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="#E4FBCC"
|
||||
onClick={() => {
|
||||
selectedBackground = '#E4FBCC'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfirm({ type: 'emoji', icon: '🤖', background: selectedBackground })
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
iconPicker.ok
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
iconPicker.cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
@@ -118,7 +161,7 @@ describe('DuplicateAppModal', () => {
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the picker independent of the name field and submits only its confirmed icon', async () => {
|
||||
it('should update the selected icon before confirming the duplicate', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
@@ -137,22 +180,20 @@ describe('DuplicateAppModal', () => {
|
||||
|
||||
await user.click(getIconButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('radio', { name: 'app.iconPicker.color.green' }))
|
||||
await user.click(screen.getByRole('button', { name: '#E4FBCC', hidden: true }))
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/, hidden: true }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('textbox', { name: /appCustomize\.subTitle/ })).toHaveValue('Demo App')
|
||||
await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
name: 'Demo App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#F3FEE7',
|
||||
icon_background: '#E4FBCC',
|
||||
})
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
@@ -239,16 +280,33 @@ describe('DuplicateAppModal', () => {
|
||||
)
|
||||
|
||||
await user.click(getIconButton())
|
||||
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(picker).not.toBeInTheDocument())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
})
|
||||
const emojiButton = screen.getByText('😀').closest('button')
|
||||
expect(emojiButton).toBeTruthy()
|
||||
await user.click(emojiButton!)
|
||||
await user.click(screen.getByRole('button', { name: '#E4FBCC', hidden: true }))
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/, hidden: true }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
})
|
||||
await user.click(getIconButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/, hidden: true }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Image App',
|
||||
icon_type: 'image',
|
||||
icon: 'original-file',
|
||||
icon_type: 'emoji',
|
||||
icon: expect.any(String),
|
||||
icon_background: '#E4FBCC',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { AppDetailWithSite, CopyAppPayload } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { IconPickerInputValue, IconPickerValue } from '@/app/components/base/icon-picker'
|
||||
import type { IconPickerDefaultValue, IconPickerValue } from '@/app/components/base/icon-picker'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
@@ -11,6 +11,7 @@ import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { toast } from '@/app/notifications'
|
||||
@@ -42,16 +43,15 @@ const DuplicateAppModal = ({
|
||||
|
||||
const [name, setName] = React.useState(appName)
|
||||
|
||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||
const [selectedIcon, setSelectedIcon] = useState<IconPickerValue | null>(null)
|
||||
const pickerValue: IconPickerInputValue | undefined =
|
||||
const pickerDefaultValue: IconPickerDefaultValue | undefined =
|
||||
selectedIcon ??
|
||||
(icon_type === 'image' && icon
|
||||
? { type: 'image', url: icon_url ?? '', fileId: icon }
|
||||
: icon_type === 'emoji' && icon
|
||||
? { type: 'emoji', icon, background: icon_background }
|
||||
: icon_type === 'link' && icon
|
||||
? { type: 'link', url: icon }
|
||||
: undefined)
|
||||
: undefined)
|
||||
const currentIcon = selectedIcon
|
||||
? {
|
||||
icon_type: selectedIcon.type,
|
||||
@@ -119,27 +119,38 @@ const DuplicateAppModal = ({
|
||||
}}
|
||||
>
|
||||
<div className="mb-9 system-sm-regular text-text-secondary">
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
|
||||
<IconPickerDialog
|
||||
value={pickerValue}
|
||||
onConfirm={setSelectedIcon}
|
||||
size="large"
|
||||
className="col-start-1 row-start-2"
|
||||
aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}`}
|
||||
/>
|
||||
<Field name="name" className="contents">
|
||||
<FieldLabel className="col-span-2 row-start-1 system-md-medium">
|
||||
{t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}
|
||||
</FieldLabel>
|
||||
<Field name="name">
|
||||
<FieldLabel className="system-md-medium">
|
||||
{t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}
|
||||
</FieldLabel>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}`}
|
||||
onClick={() => {
|
||||
setShowIconPicker(true)
|
||||
}}
|
||||
className="shrink-0 cursor-pointer rounded-[10px] focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={currentIcon.icon_type === 'link' ? 'image' : currentIcon.icon_type}
|
||||
icon={currentIcon.icon ?? undefined}
|
||||
background={currentIcon.icon_background}
|
||||
imageUrl={
|
||||
currentIcon.icon_type === 'link' ? currentIcon.icon : currentIcon.icon_url
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-start-2 row-start-2 h-10"
|
||||
className="h-10"
|
||||
placeholder={t(($) => $['placeholder.input'], { ns: 'common' }) || ''}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
{isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse">
|
||||
@@ -151,13 +162,19 @@ const DuplicateAppModal = ({
|
||||
>
|
||||
{t(($) => $.duplicate, { ns: 'app' })}
|
||||
</Button>
|
||||
<Button className="w-24" onClick={onHide}>
|
||||
<Button type="button" className="w-24" onClick={onHide}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<IconPickerDialog
|
||||
open={showIconPicker}
|
||||
defaultValue={pickerDefaultValue}
|
||||
onOpenChange={setShowIconPicker}
|
||||
onConfirm={setSelectedIcon}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
@@ -118,7 +118,7 @@ export const AppExportConfirmContent = ({
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={isExporting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -136,7 +136,7 @@ export const AppExportConfirmContent = ({
|
||||
: t(($) => $.exportApp, { ns: 'app' })}
|
||||
</span>
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import ConversationList from '../list'
|
||||
|
||||
const mockFetchChatMessages = vi.fn()
|
||||
const mockUpdateLogMessageFeedbacks = vi.fn()
|
||||
const mockFeedbackResult = vi.fn()
|
||||
const mockUpdateLogMessageAnnotations = vi.fn()
|
||||
const mockOnRefresh = vi.fn()
|
||||
const mockCompletionRefetch = vi.fn()
|
||||
@@ -188,12 +187,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await onFeedback('message-1', { rating: 'like', content: 'nice' })
|
||||
mockFeedbackResult(result)
|
||||
}}
|
||||
>
|
||||
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>
|
||||
chat-feedback
|
||||
</button>
|
||||
<button
|
||||
@@ -432,88 +426,81 @@ describe('ConversationList', () => {
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'returns the actual admin feedback result (success: %s)',
|
||||
async (succeeded) => {
|
||||
mockFeedbackResult.mockClear()
|
||||
if (!succeeded)
|
||||
mockUpdateLogMessageFeedbacks.mockRejectedValueOnce(new Error('Feedback failed'))
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
variable: 'query',
|
||||
},
|
||||
},
|
||||
],
|
||||
it('should render chat conversation details and submit feedback from the chat panel', async () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
message: {
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
variable: 'query',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
message: {
|
||||
inputs: {
|
||||
query: 'Latest question',
|
||||
},
|
||||
},
|
||||
}
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
answer: 'Assistant reply',
|
||||
query: 'Latest question',
|
||||
created_at: 1710000000,
|
||||
inputs: {
|
||||
query: 'Latest question',
|
||||
},
|
||||
feedbacks: [],
|
||||
message: [],
|
||||
message_files: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
renderConversationList({
|
||||
searchParams: '?page=2&conversation_id=conversation-1',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/chat-messages',
|
||||
params: {
|
||||
conversation_id: 'conversation-1',
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
answer: 'Assistant reply',
|
||||
query: 'Latest question',
|
||||
created_at: 1710000000,
|
||||
inputs: {
|
||||
query: 'Latest question',
|
||||
},
|
||||
feedbacks: [],
|
||||
message: [],
|
||||
message_files: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
})
|
||||
})
|
||||
|
||||
renderConversationList({
|
||||
searchParams: '?page=2&conversation_id=conversation-1',
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
|
||||
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
|
||||
|
||||
fireEvent.click(screen.getByText('chat-feedback'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/feedbacks',
|
||||
body: {
|
||||
message_id: 'message-1',
|
||||
rating: 'like',
|
||||
content: 'nice',
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/chat-messages',
|
||||
params: {
|
||||
conversation_id: 'conversation-1',
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
|
||||
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
|
||||
|
||||
fireEvent.click(screen.getByText('chat-feedback'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/feedbacks',
|
||||
body: {
|
||||
message_id: 'message-1',
|
||||
rating: 'like',
|
||||
content: 'nice',
|
||||
},
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(mockFeedbackResult).toHaveBeenCalledWith(succeeded))
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['chatbot', AppModeEnum.CHAT, 'false'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import type { FC } from 'react'
|
||||
import { buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type CustomizeDialogProps = {
|
||||
disabled?: boolean
|
||||
triggerLabel?: string
|
||||
type IShareLinkProps = {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
api_base_url: string
|
||||
appId: string
|
||||
mode?: AppMode
|
||||
sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator'
|
||||
}
|
||||
|
||||
const StepNum = ({ children }: { children: ReactNode }) => (
|
||||
const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="mr-3 flex size-7 shrink-0 items-center justify-center rounded-2xl bg-util-colors-blue-blue-50 text-text-accent">
|
||||
{children}
|
||||
</div>
|
||||
@@ -52,14 +52,14 @@ const GithubIcon = ({ className }: { className: string }) => {
|
||||
|
||||
const prefixCustomize = 'overview.appInfo.customize'
|
||||
|
||||
export function CustomizeDialog({
|
||||
disabled,
|
||||
triggerLabel,
|
||||
const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
appId,
|
||||
api_base_url,
|
||||
mode,
|
||||
sourceCodeRepository,
|
||||
}: CustomizeDialogProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation(['appOverview', 'common'])
|
||||
const docLink = useDocLink()
|
||||
const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT
|
||||
@@ -68,14 +68,7 @@ export function CustomizeDialog({
|
||||
const apiDocLink = docLink('/api-reference/guides/get-started')
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
disabled={disabled}
|
||||
render={<Button variant="secondary" className="flex items-center gap-1 px-3" />}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
{triggerLabel ?? t(($) => $['overview.appInfo.customize.entry'], { ns: 'appOverview' })}
|
||||
</DialogTrigger>
|
||||
<Dialog open={isShow} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-160 flex-col overflow-hidden!">
|
||||
<DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $[`${prefixCustomize}.title`], { ns: 'appOverview' })}
|
||||
@@ -191,3 +184,5 @@ export function CustomizeDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomizeModal
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
import { EmbeddedDialog } from '../index'
|
||||
|
||||
const { getProfile } = vi.hoisted(() => ({ getProfile: vi.fn() }))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: getProfile,
|
||||
post: vi.fn(),
|
||||
request: vi.fn(),
|
||||
getPublic: vi.fn(),
|
||||
getMarketplace: vi.fn(),
|
||||
postPublic: vi.fn(),
|
||||
postMarketplace: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
delPublic: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
patchPublic: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
ssePost: vi.fn(),
|
||||
sseGet: vi.fn(),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
handleStream: vi.fn(),
|
||||
buildSigninUrlWithRedirect: vi.fn(),
|
||||
isWebAppSigninPath: vi.fn(),
|
||||
buildWebAppSigninUrlWithRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
const prefix = 'appOverview.overview.appInfo.embedded'
|
||||
const triggerLabel = 'deployments.studio.accessPoint.embedIntoSite'
|
||||
const hiddenInputs = [
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: 'initial value',
|
||||
},
|
||||
]
|
||||
|
||||
function createClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } })
|
||||
}
|
||||
|
||||
function dialogView(queryClient: QueryClient, props: ComponentProps<typeof EmbeddedDialog> = {}) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EmbeddedDialog
|
||||
accessToken="embed-token"
|
||||
appBaseUrl="https://app.example.com"
|
||||
hiddenInputs={hiddenInputs}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('keeps Close focused while account data loads and returns to the keyboard trigger', async () => {
|
||||
const queryClient = createClient()
|
||||
const { profile } = seedAccountProfileQuery(createClient())
|
||||
let resolveProfile!: (response: Response) => void
|
||||
getProfile.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveProfile = resolve
|
||||
}),
|
||||
)
|
||||
const screen = await render(dialogView(queryClient))
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await userEvent.tab()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
|
||||
const close = dialog.getByRole('button', { name: 'common.operation.close' })
|
||||
await expect.element(close).toHaveFocus()
|
||||
const initialClose = close.element()
|
||||
await expect.poll(() => getProfile.mock.calls.length).toBe(1)
|
||||
await expect
|
||||
.element(dialog.getByRole('tab', { name: `${prefix}.iframe` }))
|
||||
.not.toBeInTheDocument()
|
||||
|
||||
resolveProfile(
|
||||
new Response(JSON.stringify(profile), {
|
||||
headers: { 'content-type': 'application/json', 'x-env': 'PRODUCTION' },
|
||||
}),
|
||||
)
|
||||
await expect.element(dialog.getByRole('tab', { name: `${prefix}.iframe` })).toBeVisible()
|
||||
expect(close.element()).toBe(initialClose)
|
||||
await expect.element(close).toHaveFocus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('preserves the hidden input, selected tab and copied preview through exit, then starts fresh', async () => {
|
||||
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
|
||||
const queryClient = createClient()
|
||||
seedAccountProfileQuery(queryClient)
|
||||
const screen = await render(dialogView(queryClient))
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
|
||||
const disclosure = dialog.getByRole('button', {
|
||||
name: new RegExp(`${prefix}.hiddenInputs.title`),
|
||||
})
|
||||
await expect.element(disclosure).toHaveAttribute('aria-expanded', 'false')
|
||||
await disclosure.click()
|
||||
const input = dialog.getByRole('textbox', { name: 'Secret' })
|
||||
await input.fill('draft secret')
|
||||
const scriptsTab = dialog.getByRole('tab', { name: `${prefix}.scripts` })
|
||||
await scriptsTab.click()
|
||||
const panel = dialog.getByRole('tabpanel', { name: `${prefix}.scripts` })
|
||||
await expect.poll(() => panel.element().textContent).toContain('draft secret')
|
||||
await panel.getByRole('button', { name: `${prefix}.copy`, exact: true }).click()
|
||||
const copied = panel.getByRole('button', { name: `${prefix}.copied`, exact: true })
|
||||
await expect.element(copied).toBeVisible()
|
||||
expect(copy).toHaveBeenLastCalledWith(expect.stringContaining('draft secret'))
|
||||
|
||||
const popup = dialog.element()
|
||||
const draftInput = input.element() as HTMLInputElement
|
||||
const activeTab = scriptsTab.element()
|
||||
const preview = panel.element()
|
||||
const copyButton = copied.element()
|
||||
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
|
||||
const exitFrame = new Promise<{
|
||||
draft: string
|
||||
selected: string | null
|
||||
preview: string | null
|
||||
copied: string | null
|
||||
opacity: number
|
||||
}>((resolve) => {
|
||||
const onTransition = (event: Event) => {
|
||||
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
|
||||
popup.removeEventListener('transitionrun', onTransition)
|
||||
resolve({
|
||||
draft: draftInput.value,
|
||||
selected: activeTab.getAttribute('aria-selected'),
|
||||
preview: preview.textContent,
|
||||
copied: copyButton.getAttribute('aria-label'),
|
||||
opacity: Number(getComputedStyle(popup).opacity),
|
||||
})
|
||||
}
|
||||
popup.addEventListener('transitionrun', onTransition)
|
||||
})
|
||||
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
const closing = await exitFrame
|
||||
expect(closing.opacity).toBeGreaterThan(0)
|
||||
expect(closing.draft).toBe('draft secret')
|
||||
expect(closing.selected).toBe('true')
|
||||
expect(closing.preview).toContain('draft secret')
|
||||
expect(closing.copied).toBe(`${prefix}.copied`)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
|
||||
await userEvent.keyboard(' ')
|
||||
await expect
|
||||
.element(dialog.getByRole('tab', { name: `${prefix}.iframe` }))
|
||||
.toHaveAttribute('aria-selected', 'true')
|
||||
await expect.element(disclosure).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: `${prefix}.copy`, exact: true }))
|
||||
.toBeVisible()
|
||||
await disclosure.click()
|
||||
await expect.element(input).toHaveValue('initial value')
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('ends the active session when disabled or missing its token and keeps a disabled entry', async () => {
|
||||
const queryClient = createClient()
|
||||
seedAccountProfileQuery(queryClient)
|
||||
const screen = await render(dialogView(queryClient))
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
|
||||
for (const unavailable of [{ disabled: true }, { accessToken: undefined }]) {
|
||||
await trigger.click()
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await screen.rerender(dialogView(queryClient, unavailable))
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toBeDisabled()
|
||||
await screen.rerender(dialogView(queryClient))
|
||||
await expect.element(trigger).not.toBeDisabled()
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { act } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { EmbeddedDialog } from '../index'
|
||||
import Embedded from '../index'
|
||||
|
||||
vi.mock('../style.module.css', () => ({
|
||||
default: {
|
||||
@@ -33,14 +33,17 @@ const siteInfo: SiteInfo = {
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
isShow: true,
|
||||
siteInfo,
|
||||
onClose: vi.fn(),
|
||||
appBaseUrl: 'https://app.example.com',
|
||||
accessToken: 'token',
|
||||
className: 'custom-modal',
|
||||
}
|
||||
|
||||
const getCopyButton = () => screen.getByRole('button', { name: /copy/i })
|
||||
|
||||
describe('EmbeddedDialog', () => {
|
||||
describe('Embedded', () => {
|
||||
beforeAll(() => {
|
||||
class MockCompressionStream {
|
||||
readable: ReadableStream<Uint8Array>
|
||||
@@ -71,9 +74,8 @@ describe('EmbeddedDialog', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -95,9 +97,8 @@ describe('EmbeddedDialog', () => {
|
||||
it('links each embed method tab to a panel and supports arrow key selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
await act(async () => {
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
const iframe = screen.getByRole('tab', {
|
||||
name: 'appOverview.overview.appInfo.embedded.iframe',
|
||||
@@ -134,9 +135,8 @@ describe('EmbeddedDialog', () => {
|
||||
it('opens chrome plugin store link when chrome option selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
await act(async () => {
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
@@ -157,46 +157,23 @@ describe('EmbeddedDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('starts a fresh tab session after closing and reopening', async () => {
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
const trigger = screen.getByRole('button', { name: /embedIntoSite/ })
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('tab', { name: /embedded.scripts/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('tab', { name: /embedded.iframe/ })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
const onClose = vi.fn()
|
||||
|
||||
it.each(['disabled', 'missing-token'] as const)(
|
||||
'keeps a visible unavailable trigger for %s and ends its session',
|
||||
async (reason) => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<EmbeddedDialog {...baseProps} />)
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
rerender(
|
||||
<EmbeddedDialog
|
||||
{...baseProps}
|
||||
disabled={reason === 'disabled'}
|
||||
accessToken={reason === 'missing-token' ? undefined : baseProps.accessToken}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /embedIntoSite/ })).toBeDisabled()
|
||||
rerender(<EmbeddedDialog {...baseProps} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EmbeddedDialog
|
||||
<Embedded
|
||||
{...baseProps}
|
||||
hiddenInputs={[
|
||||
{
|
||||
@@ -211,8 +188,6 @@ describe('EmbeddedDialog', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
@@ -256,9 +231,8 @@ describe('EmbeddedDialog', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
@@ -284,9 +258,8 @@ describe('EmbeddedDialog', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
|
||||
@@ -5,16 +5,8 @@ import type {
|
||||
WorkflowHiddenStartVariable,
|
||||
WorkflowLaunchInputValue,
|
||||
} from '../app-card-utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
@@ -37,14 +29,15 @@ import {
|
||||
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
|
||||
import style from './style.module.css'
|
||||
|
||||
type EmbeddedDialogProps = Readonly<{
|
||||
type Props = Readonly<{
|
||||
siteInfo?: Partial<Pick<AppDetailSiteResponse, 'chat_color_theme' | 'chat_color_theme_inverted'>>
|
||||
disabled?: boolean
|
||||
triggerLabel?: string
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
accessToken?: string
|
||||
appBaseUrl?: string
|
||||
webAppRoute?: EmbeddedWebAppRoute
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
|
||||
@@ -121,8 +114,8 @@ const EmbeddedContent = ({
|
||||
accessToken,
|
||||
webAppRoute = 'chatbot',
|
||||
hiddenInputs,
|
||||
}: Required<Pick<EmbeddedDialogProps, 'accessToken' | 'appBaseUrl'>> &
|
||||
Pick<EmbeddedDialogProps, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => {
|
||||
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> &
|
||||
Pick<Props, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => {
|
||||
const { t } = useTranslation(['appOverview'])
|
||||
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
|
||||
@@ -220,15 +213,14 @@ const EmbeddedContent = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogDescription className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
{t(($) => $[`${prefixEmbedded}.explanation`], { ns: 'appOverview' })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{supportedHiddenInputs.length > 0 && (
|
||||
<div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!hiddenInputsCollapsed}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||||
onClick={() => setHiddenInputsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<div>
|
||||
@@ -376,30 +368,29 @@ const EmbeddedContent = ({
|
||||
)
|
||||
}
|
||||
|
||||
export function EmbeddedDialog({
|
||||
const Embedded = ({
|
||||
siteInfo,
|
||||
disabled,
|
||||
triggerLabel,
|
||||
isShow,
|
||||
onClose,
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute = 'chatbot',
|
||||
hiddenInputs,
|
||||
}: EmbeddedDialogProps) {
|
||||
const { t } = useTranslation(['appOverview', 'common', 'deployments'])
|
||||
const unavailable = disabled || !accessToken
|
||||
const trigger = (
|
||||
<Button variant="secondary" disabled={unavailable} className="px-3">
|
||||
<span aria-hidden className="i-ri-window-line size-4" />
|
||||
{triggerLabel ?? t(($) => $['studio.accessPoint.embedIntoSite'], { ns: 'deployments' })}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (unavailable) return trigger
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation(['appOverview', 'common'])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger render={trigger} />
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-160 flex-col overflow-hidden!">
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn('flex max-h-[calc(100dvh-2rem)] w-160 flex-col overflow-hidden!', className)}
|
||||
>
|
||||
<DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $[`${prefixEmbedded}.title`], { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
@@ -415,22 +406,20 @@ export function EmbeddedDialog({
|
||||
}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-8 text-text-tertiary">{t(($) => $.loading, { ns: 'common' })}</div>
|
||||
}
|
||||
>
|
||||
{isShow && (
|
||||
<EmbeddedContent
|
||||
key={`${appBaseUrl ?? ''}:${accessToken}:${webAppRoute}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${webAppRoute}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
siteInfo={siteInfo}
|
||||
appBaseUrl={appBaseUrl ?? ''}
|
||||
accessToken={accessToken}
|
||||
accessToken={accessToken ?? ''}
|
||||
webAppRoute={webAppRoute}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Embedded
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { SettingsAppInfo } from '../index'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
} from '@/test/console/query-data'
|
||||
import { createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { SettingsDialog } from '../index'
|
||||
import SettingsModal from '../index'
|
||||
|
||||
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -65,7 +64,8 @@ vi.mock('@/app/notifications', () => ({
|
||||
promise: toastMocks.promise,
|
||||
}),
|
||||
}))
|
||||
const mockOnSave = vi.fn<(params: AppSiteUpdatePayload) => Promise<boolean>>()
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
|
||||
vi.mock('@/context/i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
|
||||
@@ -97,16 +97,17 @@ const mockAppInfo = {
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
} satisfies SettingsAppInfo
|
||||
|
||||
const triggerName = 'navigation.settings.settings'
|
||||
const dialogName = 'appOverview.overview.appInfo.settings.title'
|
||||
|
||||
const renderSettingsDialog = async (appInfo: SettingsAppInfo = mockAppInfo, canDeploy = false) => {
|
||||
const view = render(
|
||||
<SettingsDialog isChat canDeploy={canDeploy} appInfo={appInfo} onSave={mockOnSave} />,
|
||||
const renderSettingsModal = (appInfo: SettingsAppInfo = mockAppInfo, canDeploy = false) =>
|
||||
render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={canDeploy}
|
||||
isShow
|
||||
appInfo={appInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: triggerName }))
|
||||
return view
|
||||
}
|
||||
|
||||
const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder'
|
||||
|
||||
@@ -119,10 +120,11 @@ function render(...args: Parameters<typeof renderWithData>) {
|
||||
return { ...result, rerender: (ui: ReactElement) => result.rerender(wrap(ui)) }
|
||||
}
|
||||
|
||||
describe('SettingsDialog', () => {
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
toastMocks.call.mockClear()
|
||||
mockOnSave.mockReset().mockResolvedValue(true)
|
||||
mockOnClose.mockClear()
|
||||
mockOnSave.mockClear()
|
||||
onPricingUrlUpdate.mockClear()
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = true
|
||||
@@ -133,11 +135,7 @@ describe('SettingsDialog', () => {
|
||||
})
|
||||
|
||||
it('edits a site with nullable optional fields as empty form values', async () => {
|
||||
await renderSettingsDialog({
|
||||
id: 'app-null-fields',
|
||||
mode: 'chat',
|
||||
site: createAppSiteFixture(),
|
||||
})
|
||||
renderSettingsModal({ id: 'app-null-fields', mode: 'chat', site: createAppSiteFixture() })
|
||||
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('')
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
@@ -156,13 +154,11 @@ describe('SettingsDialog', () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mockOnClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should render the modal with all settings exposed by default', async () => {
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
@@ -182,8 +178,8 @@ describe('SettingsDialog', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should explain that Web app settings apply to every environment when ACL allows deploy', async () => {
|
||||
await renderSettingsDialog(mockAppInfo, true)
|
||||
it('should explain that Web app settings apply to every environment when ACL allows deploy', () => {
|
||||
renderSettingsModal(mockAppInfo, true)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'appOverview.overview.appInfo.settings.multiEnvironmentNotice',
|
||||
@@ -192,7 +188,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
it('names the Inverted switch from its visible label and exposes its state', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
|
||||
const invertedSwitch = screen.getByRole('switch', {
|
||||
name: 'appOverview.overview.appInfo.settings.chatColorThemeInverted',
|
||||
@@ -205,7 +201,7 @@ describe('SettingsDialog', () => {
|
||||
})
|
||||
|
||||
it('should notify the user when the name is empty', async () => {
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
@@ -219,7 +215,7 @@ describe('SettingsDialog', () => {
|
||||
})
|
||||
|
||||
it('should validate the theme color and show an error when the hex is invalid', async () => {
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
const colorInput = screen.getByPlaceholderText('E.g #A020F0')
|
||||
fireEvent.change(colorInput, { target: { value: 'not-a-hex' } })
|
||||
|
||||
@@ -235,7 +231,7 @@ describe('SettingsDialog', () => {
|
||||
})
|
||||
|
||||
it('should validate the privacy policy URL', async () => {
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
const privacyInput = screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
)
|
||||
@@ -254,8 +250,8 @@ describe('SettingsDialog', () => {
|
||||
})
|
||||
|
||||
it('should save valid settings and close the modal', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
await renderSettingsDialog()
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
renderSettingsModal()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
@@ -279,48 +275,98 @@ describe('SettingsDialog', () => {
|
||||
use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon,
|
||||
}),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards cancelled edits and initializes the next session from committed settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderSettingsDialog()
|
||||
const placeholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
await user.clear(placeholder)
|
||||
await user.type(placeholder, 'Cancelled prompt')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mockOnSave).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: triggerName }))
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue(
|
||||
'Ask me anything',
|
||||
)
|
||||
it('should not render a show-more trigger', () => {
|
||||
renderSettingsModal()
|
||||
|
||||
expect(
|
||||
screen.queryByText('appOverview.overview.appInfo.settings.more.entry'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps drafts for equivalent source data and resets them when committed settings change', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = await renderSettingsDialog()
|
||||
await user.clear(screen.getByRole('textbox', { name: inputPlaceholderName }))
|
||||
await user.type(screen.getByRole('textbox', { name: inputPlaceholderName }), 'Current draft')
|
||||
rerender(
|
||||
<SettingsDialog
|
||||
it('should reset local form state when the controlled dialog reopens', () => {
|
||||
const { rerender } = render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
appInfo={{ ...mockAppInfo, site: { ...mockAppInfo.site } }}
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('Current draft')
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SettingsDialog
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={false}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
rerender(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByText('appOverview.overview.appInfo.settings.more.entry'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset the input placeholder when app info changes while open', () => {
|
||||
const { rerender } = render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue(
|
||||
'Ask me anything',
|
||||
)
|
||||
|
||||
rerender(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={{
|
||||
...mockAppInfo,
|
||||
site: { ...mockAppInfo.site, input_placeholder: 'Updated prompt' },
|
||||
site: {
|
||||
...mockAppInfo.site,
|
||||
input_placeholder: 'Updated prompt',
|
||||
},
|
||||
}}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
@@ -329,138 +375,12 @@ describe('SettingsDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['false', 'rejection'] as const)(
|
||||
'retains the draft after a %s save result and permits retry',
|
||||
async (failure) => {
|
||||
const user = userEvent.setup()
|
||||
if (failure === 'false') mockOnSave.mockResolvedValueOnce(false)
|
||||
else mockOnSave.mockRejectedValueOnce(new Error('Unexpected failure'))
|
||||
await renderSettingsDialog()
|
||||
const name = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
await user.clear(name)
|
||||
await user.type(name, 'Unsaved title')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() => expect(name).not.toHaveAttribute('readonly'))
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
expect(name).toHaveValue('Unsaved title')
|
||||
expect(toastMocks.call).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(2)
|
||||
expect(mockOnSave).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ title: 'Unsaved title' }),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the pending lock when committed metadata replaces the form draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finishSave!: (saved: boolean) => void
|
||||
mockOnSave.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishSave = resolve
|
||||
}),
|
||||
)
|
||||
const { rerender } = await renderSettingsDialog()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
rerender(
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={{ ...mockAppInfo, site: { ...mockAppInfo.site, title: 'Refreshed title' } }}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
const name = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
expect(name).toHaveValue('Refreshed title')
|
||||
expect(name).toHaveAttribute('readonly')
|
||||
await user.type(name, 'ignored')
|
||||
expect(name).toHaveValue('Refreshed title')
|
||||
expect(screen.getByRole('button', { name: 'app.iconPicker.title' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
|
||||
const invertedSwitch = screen.getByRole('switch', {
|
||||
name: 'appOverview.overview.appInfo.settings.chatColorThemeInverted',
|
||||
})
|
||||
await user.click(invertedSwitch)
|
||||
expect(invertedSwitch).toBeChecked()
|
||||
const language = screen.getByRole('combobox', {
|
||||
name: 'appOverview.overview.appInfo.settings.language',
|
||||
})
|
||||
const currentLanguage = language.textContent
|
||||
await user.click(language)
|
||||
await user.click(screen.getByRole('option', { name: '简体中文' }))
|
||||
expect(language).toHaveTextContent(currentLanguage!)
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(mockOnSave).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
finishSave(true)
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['missing-data', 'disabled'] as const)(
|
||||
'keeps an unavailable %s entry visible until ready',
|
||||
async (unavailable) => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={unavailable === 'missing-data' ? undefined : mockAppInfo}
|
||||
disabled={unavailable === 'disabled'}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: triggerName })
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
|
||||
rerender(<SettingsDialog isChat appInfo={mockAppInfo} onSave={mockOnSave} />)
|
||||
await user.click(screen.getByRole('button', { name: triggerName }))
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an unfinished save locked when permission disappears and returns', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finishSave!: (saved: boolean) => void
|
||||
mockOnSave.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishSave = resolve
|
||||
}),
|
||||
)
|
||||
const { rerender } = await renderSettingsDialog()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
rerender(<SettingsDialog isChat appInfo={mockAppInfo} disabled onSave={mockOnSave} />)
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
|
||||
rerender(<SettingsDialog isChat appInfo={mockAppInfo} onSave={mockOnSave} />)
|
||||
const trigger = screen.getByRole('button', { name: triggerName })
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
finishSave(true)
|
||||
})
|
||||
await waitFor(() => expect(trigger).toBeEnabled())
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
expect(mockOnSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should preserve restricted settings when saving other Cloud settings', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = false
|
||||
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
|
||||
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
expect(inputPlaceholder).toBeDisabled()
|
||||
@@ -484,11 +404,11 @@ describe('SettingsDialog', () => {
|
||||
})
|
||||
|
||||
it('should keep the input placeholder editable when billing is disabled', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
copyrightEnabled = false
|
||||
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
fireEvent.change(inputPlaceholder, { target: { value: 'Self-hosted prompt' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
@@ -509,7 +429,7 @@ describe('SettingsDialog', () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = false
|
||||
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -521,14 +441,14 @@ describe('SettingsDialog', () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = true
|
||||
|
||||
await renderSettingsDialog()
|
||||
renderSettingsModal()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve image icons and apply textarea or switch changes when saving image-based settings', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
const imageAppInfo = {
|
||||
...mockAppInfo,
|
||||
site: {
|
||||
@@ -540,7 +460,7 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
} satisfies SettingsAppInfo
|
||||
|
||||
await renderSettingsDialog(imageAppInfo)
|
||||
renderSettingsModal(imageAppInfo)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('A description'), {
|
||||
target: { value: 'Updated description' },
|
||||
@@ -591,13 +511,12 @@ it('saves unrelated settings while entitlements are pending without clearing pro
|
||||
...consoleQuery.features.get.queryOptions(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
const onSave = vi.fn().mockResolvedValue(true)
|
||||
render(<SettingsDialog isChat appInfo={mockAppInfo} onSave={onSave} />, {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(<SettingsModal isChat isShow appInfo={mockAppInfo} onClose={vi.fn()} onSave={onSave} />, {
|
||||
queryClient,
|
||||
features: undefined,
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: triggerName }))
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeDisabled()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { useAccessPointActions } from '@/app/components/app/access-point/shared/use-access-point-actions'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { emojiCatalogOptions } from '@/app/components/base/icon-picker/emoji-data'
|
||||
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { QueryClientTestProvider } from '@/test/console/query-provider'
|
||||
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { SettingsDialog } from '../index'
|
||||
|
||||
const { saveSite, fetchApp, notify } = vi.hoisted(() => ({
|
||||
saveSite: vi.fn(),
|
||||
fetchApp: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: {
|
||||
...actual.consoleClient,
|
||||
apps: {
|
||||
...actual.consoleClient.apps,
|
||||
byAppId: {
|
||||
...actual.consoleClient.apps.byAppId,
|
||||
get: fetchApp,
|
||||
site: { ...actual.consoleClient.apps.byAppId.site, post: saveSite },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: Object.assign(notify, { success: notify, error: notify, warning: notify, info: notify }),
|
||||
}))
|
||||
vi.mock('@/next/navigation', () => ({ useParams: () => ({}) }))
|
||||
|
||||
const settingsTitle = 'appOverview.overview.appInfo.settings.title'
|
||||
const titleLabel = 'appOverview.overview.appInfo.settings.webName'
|
||||
const triggerLabel = 'navigation.settings.settings'
|
||||
|
||||
function createApp(title = 'Original app'): AppDetailWithSite {
|
||||
return createAppDetailFixture({
|
||||
id: 'settings-app',
|
||||
mode: 'advanced-chat',
|
||||
site: createAppSiteFixture({
|
||||
title,
|
||||
description: 'Original description',
|
||||
icon_type: 'emoji',
|
||||
icon: '😀',
|
||||
icon_background: '#FEF3F2',
|
||||
default_language: 'en-US',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function SettingsOwner() {
|
||||
const appInfo = useAppStore((state) => state.appDetail)
|
||||
const { saveSiteConfig } = useAccessPointActions('settings-app', true)
|
||||
return (
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={
|
||||
appInfo?.site ? { id: appInfo.id, mode: appInfo.mode, site: appInfo.site } : undefined
|
||||
}
|
||||
onSave={saveSiteConfig}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderSettings() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, retry: false } },
|
||||
})
|
||||
seedSystemFeatures(client, { deployment_edition: 'COMMUNITY' })
|
||||
seedFeatures(client, { webapp_copyright_enabled: true })
|
||||
client.setQueryData(emojiCatalogOptions.queryKey, [
|
||||
{
|
||||
id: 'faces',
|
||||
label: 'Faces',
|
||||
items: [{ emoji: '😎', label: 'Cool face', version: 1 }],
|
||||
},
|
||||
])
|
||||
const screen = await render(
|
||||
<QueryClientTestProvider queryClient={client}>
|
||||
<NuqsTestingAdapter>
|
||||
<SettingsOwner />
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientTestProvider>,
|
||||
)
|
||||
return { screen, client }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({ appDetail: createApp() })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
})
|
||||
|
||||
it('retains a failed save draft and closes a successful retry before the background refresh finishes', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
saveSite.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectSave = reject
|
||||
}),
|
||||
)
|
||||
saveSite.mockResolvedValueOnce({})
|
||||
let finishRefresh!: (value: AppDetailWithSite) => void
|
||||
fetchApp.mockReturnValueOnce(
|
||||
new Promise<AppDetailWithSite>((resolve) => {
|
||||
finishRefresh = resolve
|
||||
}),
|
||||
)
|
||||
const { screen, client } = await renderSettings()
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: settingsTitle })
|
||||
const title = dialog.getByRole('textbox', { name: titleLabel })
|
||||
await title.fill('Saved after retry')
|
||||
const save = dialog.getByRole('button', { name: 'common.operation.save' })
|
||||
await save.click()
|
||||
await expect.element(save).toHaveFocus()
|
||||
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(title).toHaveAttribute('readonly')
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.close' }))
|
||||
.toBeDisabled()
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
.toBeDisabled()
|
||||
await userEvent.keyboard('{Enter}{Escape}')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
expect(saveSite).toHaveBeenCalledTimes(1)
|
||||
expect(fetchApp).not.toHaveBeenCalled()
|
||||
rejectSave(new Error('Save failed'))
|
||||
await expect.element(save).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(title).not.toHaveAttribute('readonly')
|
||||
await expect.element(title).toHaveValue('Saved after retry')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
|
||||
await save.click()
|
||||
await expect.poll(() => fetchApp.mock.calls.length).toBe(1)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(saveSite).toHaveBeenCalledTimes(2)
|
||||
expect(useAppStore.getState().appDetail?.site?.title).toBe('Original app')
|
||||
const refreshed = createApp('Saved after retry')
|
||||
finishRefresh(refreshed)
|
||||
await expect.poll(() => useAppStore.getState().appDetail).toEqual(refreshed)
|
||||
await trigger.click()
|
||||
await expect.element(title).toHaveValue('Saved after retry')
|
||||
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
expect(saveSite).toHaveBeenLastCalledWith({
|
||||
params: { app_id: 'settings-app' },
|
||||
body: expect.objectContaining({ title: 'Saved after retry' }),
|
||||
})
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('closes after a successful POST even when the background detail refresh fails', async () => {
|
||||
saveSite.mockResolvedValueOnce({})
|
||||
fetchApp.mockRejectedValueOnce(new Error('Refresh failed'))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { screen, client } = await renderSettings()
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: settingsTitle })
|
||||
await dialog.getByRole('textbox', { name: titleLabel }).fill('Saved remotely')
|
||||
await dialog.getByRole('button', { name: 'common.operation.save' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await expect.poll(() => fetchApp.mock.calls.length).toBe(1)
|
||||
expect(notify).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
|
||||
type: 'success',
|
||||
})
|
||||
expect(saveSite).toHaveBeenCalledTimes(1)
|
||||
client.clear()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the draft through the exit animation and reopens with the latest owner metadata', async () => {
|
||||
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
|
||||
const { screen, client } = await renderSettings()
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await userEvent.tab()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const dialog = screen.getByRole('dialog', { name: settingsTitle })
|
||||
const title = dialog.getByRole('textbox', { name: titleLabel })
|
||||
await title.fill('Cancelled draft')
|
||||
const popup = dialog.element()
|
||||
const input = title.element() as HTMLInputElement
|
||||
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
|
||||
const exitFrame = new Promise<{ value: string; opacity: number }>((resolve) => {
|
||||
const onTransition = (event: Event) => {
|
||||
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
|
||||
popup.removeEventListener('transitionrun', onTransition)
|
||||
resolve({ value: input.value, opacity: Number(getComputedStyle(popup).opacity) })
|
||||
}
|
||||
popup.addEventListener('transitionrun', onTransition)
|
||||
})
|
||||
await dialog.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
const closing = await exitFrame
|
||||
expect(closing.value).toBe('Cancelled draft')
|
||||
expect(closing.opacity).toBeGreaterThan(0)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
useAppStore.setState({ appDetail: createApp('Fresh metadata') })
|
||||
await userEvent.keyboard(' ')
|
||||
await expect.element(title).toHaveValue('Fresh metadata')
|
||||
await title.fill('Another local draft')
|
||||
useAppStore.setState({ appDetail: createApp('Changed while open') })
|
||||
await expect.element(title).toHaveValue('Changed while open')
|
||||
await expect
|
||||
.poll(() => {
|
||||
const close = dialog.getByRole('button', { name: 'common.operation.close' }).element()
|
||||
return document.activeElement === title.element() || document.activeElement === close
|
||||
? 'settings control'
|
||||
: document.activeElement?.tagName
|
||||
})
|
||||
.toBe('settings control')
|
||||
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('keeps icon search and confirmation inside the nested picker without submitting Settings', async () => {
|
||||
const { screen, client } = await renderSettings()
|
||||
await screen.getByRole('button', { name: triggerLabel }).click()
|
||||
const settings = screen.getByRole('dialog', { name: settingsTitle })
|
||||
const title = settings.getByRole('textbox', { name: titleLabel })
|
||||
await title.fill('Keep this draft')
|
||||
const iconTrigger = settings.getByRole('button', { name: 'app.iconPicker.title' })
|
||||
await iconTrigger.click()
|
||||
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
|
||||
await picker.getByRole('combobox', { name: 'app.iconPicker.search' }).fill('Cool')
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}')
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
await picker.getByRole('button', { name: 'app.iconPicker.ok' }).click()
|
||||
await expect.element(picker).not.toBeInTheDocument()
|
||||
await expect.element(settings).toBeVisible()
|
||||
await expect.element(iconTrigger).toHaveFocus()
|
||||
await expect.element(title).toHaveValue('Keep this draft')
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(picker).toBeVisible()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(picker).not.toBeInTheDocument()
|
||||
await expect.element(settings).toBeVisible()
|
||||
await expect.element(iconTrigger).toHaveFocus()
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
client.clear()
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,12 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
<button onClick={onClick}>open-icon-picker</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<AppPartial> = {}): AppPartial => ({
|
||||
id: 'app-123',
|
||||
name: 'Demo App',
|
||||
@@ -282,7 +288,7 @@ describe('SwitchAppModal', () => {
|
||||
permission_keys: ['app.acl.view_layout'],
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await user.click(screen.getByText('open-icon-picker'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
@@ -312,7 +318,7 @@ describe('SwitchAppModal', () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await user.click(screen.getByText('open-icon-picker'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -19,6 +19,7 @@ import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { toast } from '@/app/notifications'
|
||||
@@ -59,6 +60,7 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
|
||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||
const appIconType = zIconType.safeParse(sourceApp.icon_type).data
|
||||
const [appIcon, setAppIcon] = useState(
|
||||
appIconType === 'image'
|
||||
@@ -163,7 +165,21 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
{t(($) => $.switchLabel, { ns: 'app' })}
|
||||
</label>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<IconPickerDialog value={appIcon} onConfirm={setAppIcon} size="large" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['iconPicker.title'], { ns: 'app' })}
|
||||
className="shrink-0 cursor-pointer rounded-[10px] focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setShowIconPicker(true)}
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
size="large"
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
</button>
|
||||
<Input
|
||||
id={nameInputId}
|
||||
value={name}
|
||||
@@ -172,6 +188,14 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
className="h-10 grow"
|
||||
/>
|
||||
</div>
|
||||
<IconPickerDialog
|
||||
open={showIconPicker}
|
||||
defaultValue={appIcon}
|
||||
onOpenChange={setShowIconPicker}
|
||||
onConfirm={(payload) => {
|
||||
setAppIcon(payload)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isAppsFull && <AppsFull loc="app-switch" />}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
@@ -217,14 +241,14 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
{t(($) => $.deleteAppConfirmContent, { ns: 'app' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={() => setShowConfirmDelete(false)}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -10,11 +10,11 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -725,7 +725,7 @@ export function AppCardInteractions({
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton type="button" disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -736,7 +736,7 @@ export function AppCardInteractions({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import { RenameConversationDialog } from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { useChatWithHistoryContext } from './context'
|
||||
import MobileOperationDropdown from './header/mobile-operation-dropdown'
|
||||
import Operation from './header/operation'
|
||||
@@ -195,25 +195,25 @@ const HeaderInMobile = () => {
|
||||
{t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || ''}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<RenameConversationDialog
|
||||
open={!!showRename}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancelRename()
|
||||
}}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Ref } from 'react'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -17,7 +17,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
|
||||
import { RenameConversationDialog } from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import Operation from './operation'
|
||||
|
||||
@@ -183,25 +183,25 @@ const Header = ({ toggleButtonRef }: { toggleButtonRef?: Ref<HTMLButtonElement>
|
||||
{t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || ''}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<RenameConversationDialog
|
||||
open={!!showRename}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancelRename()
|
||||
}}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import { useChatWithHistoryContext } from '../../context'
|
||||
import Sidebar from '../index'
|
||||
import { RenameConversationDialog } from '../rename-modal'
|
||||
import RenameModal from '../rename-modal'
|
||||
|
||||
let mockBranding: { enabled: boolean; workspace_logo: string } = {
|
||||
enabled: false,
|
||||
@@ -523,7 +523,7 @@ describe('Sidebar Index', () => {
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Name')
|
||||
|
||||
// The RenameConversationDialog has a save button
|
||||
// The RenameModal has a save button
|
||||
const saveButton = screen.getByText('common.operation.save')
|
||||
await user.click(saveButton)
|
||||
|
||||
@@ -872,14 +872,14 @@ describe('Sidebar Index', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RenameConversationDialog', () => {
|
||||
describe('RenameModal', () => {
|
||||
it('should render title when modal is shown', () => {
|
||||
render(
|
||||
<RenameConversationDialog
|
||||
open
|
||||
<RenameModal
|
||||
isShow
|
||||
saveLoading={false}
|
||||
name="Conversation"
|
||||
onOpenChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
@@ -891,11 +891,11 @@ describe('RenameConversationDialog', () => {
|
||||
const useTranslationSpy = mockUseTranslationWithEmptyKeys(['chat.conversationNamePlaceholder'])
|
||||
try {
|
||||
render(
|
||||
<RenameConversationDialog
|
||||
open
|
||||
<RenameModal
|
||||
isShow
|
||||
saveLoading={false}
|
||||
name="Conversation"
|
||||
onOpenChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as ReactI18next from 'react-i18next'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import { RenameConversationDialog } from '../rename-modal'
|
||||
import RenameModal from '../rename-modal'
|
||||
|
||||
describe('RenameConversationDialog', () => {
|
||||
describe('RenameModal', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
isShow: true,
|
||||
saveLoading: false,
|
||||
name: 'Original Name',
|
||||
onOpenChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('RenameConversationDialog', () => {
|
||||
})
|
||||
|
||||
it('renders title, label, input and action buttons', () => {
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'common.chat.conversationName' })).toHaveValue(
|
||||
@@ -29,22 +29,22 @@ describe('RenameConversationDialog', () => {
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(<RenameConversationDialog {...defaultProps} open={false} />)
|
||||
it('does not render when isShow is false', () => {
|
||||
render(<RenameModal {...defaultProps} isShow={false} />)
|
||||
expect(screen.queryByText('common.chat.renameConversation')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onOpenChange when cancel is clicked', async () => {
|
||||
it('calls onClose when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
expect(defaultProps.onOpenChange).toHaveBeenCalled()
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSave with updated name', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
@@ -54,51 +54,33 @@ describe('RenameConversationDialog', () => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith('Updated Name')
|
||||
})
|
||||
|
||||
it('keeps the draft read-only and blocks dismissal and resubmission while saving, then allows retry', async () => {
|
||||
it('does not resubmit while save is pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
const view = render(<RenameConversationDialog {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox', { name: 'common.chat.conversationName' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Draft name')
|
||||
const save = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(save)
|
||||
expect(defaultProps.onSave).toHaveBeenCalledExactlyOnceWith('Draft name')
|
||||
render(<RenameModal {...defaultProps} saveLoading />)
|
||||
|
||||
view.rerender(<RenameConversationDialog {...defaultProps} saveLoading />)
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
expect(save).toHaveFocus()
|
||||
await user.type(input, 'ignored')
|
||||
await user.click(save)
|
||||
await user.keyboard('{Enter}{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
|
||||
expect(defaultProps.onOpenChange).not.toHaveBeenCalled()
|
||||
expect(input).toHaveValue('Draft name')
|
||||
await user.click(screen.getByRole('textbox', { name: 'common.chat.conversationName' }))
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
view.rerender(<RenameConversationDialog {...defaultProps} />)
|
||||
expect(input).not.toHaveAttribute('readonly')
|
||||
await user.click(save)
|
||||
expect(defaultProps.onSave).toHaveBeenCalledTimes(2)
|
||||
expect(defaultProps.onSave).toHaveBeenLastCalledWith('Draft name')
|
||||
expect(defaultProps.onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSave with initial name when unchanged', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith('Original Name')
|
||||
})
|
||||
|
||||
it('shows loading state when saveLoading is true', () => {
|
||||
render(<RenameConversationDialog {...defaultProps} saveLoading />)
|
||||
render(<RenameModal {...defaultProps} saveLoading />)
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
expectLoadingButton(saveButton)
|
||||
expect(saveButton.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides loading state when saveLoading is false', () => {
|
||||
render(<RenameConversationDialog {...defaultProps} saveLoading={false} />)
|
||||
render(<RenameModal {...defaultProps} saveLoading={false} />)
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
expect(saveButton).not.toBeDisabled()
|
||||
expect(saveButton).not.toHaveAttribute('aria-busy')
|
||||
@@ -107,29 +89,28 @@ describe('RenameConversationDialog', () => {
|
||||
|
||||
it('keeps edited name when parent rerenders with different name prop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<RenameConversationDialog {...defaultProps} name="First" />)
|
||||
const { rerender } = render(<RenameModal {...defaultProps} name="First" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Edited')
|
||||
|
||||
rerender(<RenameConversationDialog {...defaultProps} name="Second" />)
|
||||
rerender(<RenameModal {...defaultProps} name="Second" />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Edited')
|
||||
})
|
||||
|
||||
it('starts a fresh draft after closing finishes and the dialog opens again', async () => {
|
||||
it('retains typed state after isShow false then true on same component instance', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<RenameConversationDialog {...defaultProps} open />)
|
||||
const { rerender } = render(<RenameModal {...defaultProps} isShow />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Changed')
|
||||
|
||||
rerender(<RenameConversationDialog {...defaultProps} open={false} />)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
rerender(<RenameConversationDialog {...defaultProps} name="Latest name" open />)
|
||||
rerender(<RenameModal {...defaultProps} isShow={false} />)
|
||||
rerender(<RenameModal {...defaultProps} isShow />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Latest name')
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Changed')
|
||||
})
|
||||
|
||||
it('uses empty placeholder fallback when translation returns empty string', () => {
|
||||
@@ -149,7 +130,7 @@ describe('RenameConversationDialog', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
expect(screen.getByPlaceholderText('')).toBeInTheDocument()
|
||||
} finally {
|
||||
useTranslationSpy.mockRestore()
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Ref } from 'react'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -17,7 +17,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
|
||||
import { RenameConversationDialog } from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
|
||||
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@@ -203,25 +203,25 @@ const Sidebar = ({ isPanel, toggleButtonRef }: Props) => {
|
||||
{deleteConversationContent}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<RenameConversationDialog
|
||||
open={!!showRename}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancelRename()
|
||||
}}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ const Operation: FC<Props> = ({
|
||||
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
|
||||
data-active={isActive ? '' : undefined}
|
||||
className={cn(
|
||||
'pointer-events-none opacity-0 focus:pointer-events-auto focus:opacity-100 data-active:bg-state-accent-active data-active:text-text-accent data-active:hover:bg-state-accent-active-alt data-popup-open:pointer-events-auto data-popup-open:bg-state-base-hover data-popup-open:opacity-100 data-active:data-popup-open:bg-state-accent-active data-active:data-popup-open:text-text-accent',
|
||||
'pointer-events-none opacity-0 focus-visible:pointer-events-auto focus-visible:opacity-100 data-active:bg-state-accent-active data-active:text-text-accent data-active:hover:bg-state-accent-active-alt data-popup-open:pointer-events-auto data-popup-open:bg-state-base-hover data-popup-open:opacity-100 data-active:data-popup-open:bg-state-accent-active data-active:data-popup-open:text-text-accent',
|
||||
isItemHovering && 'pointer-events-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RenameConversationDialogProps = {
|
||||
open: boolean
|
||||
type IRenameModalProps = {
|
||||
isShow: boolean
|
||||
saveLoading: boolean
|
||||
name: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onClose: () => void
|
||||
onSave: (name: string) => void
|
||||
}
|
||||
|
||||
export function RenameConversationDialog({
|
||||
open,
|
||||
saveLoading,
|
||||
name,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: RenameConversationDialogProps) {
|
||||
const RenameModal: FC<IRenameModalProps> = ({ isShow, saveLoading, name, onClose, onSave }) => {
|
||||
const { t } = useTranslation(['common'])
|
||||
const [tempName, setTempName] = useState(name)
|
||||
const conversationNamePlaceholder =
|
||||
t(($) => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || ''
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (saveLoading) details.cancel()
|
||||
else onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<Dialog open={isShow} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['chat.renameConversation'], { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<Form<{ conversationName: string }>
|
||||
onFormSubmit={({ conversationName }) => {
|
||||
if (!saveLoading) onSave(conversationName)
|
||||
<Form
|
||||
onFormSubmit={() => {
|
||||
if (!saveLoading) onSave(tempName)
|
||||
}}
|
||||
>
|
||||
<Field name="conversationName" className="mt-6">
|
||||
@@ -48,16 +40,16 @@ export function RenameConversationDialog({
|
||||
</FieldLabel>
|
||||
<Input
|
||||
className="h-10"
|
||||
defaultValue={name}
|
||||
readOnly={saveLoading}
|
||||
value={tempName}
|
||||
onValueChange={setTempName}
|
||||
placeholder={conversationNamePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="mt-10 flex justify-end">
|
||||
<DialogClose disabled={saveLoading} render={<Button className="mr-2 shrink-0" />}>
|
||||
<Button type="button" className="mr-2 shrink-0" onClick={onClose}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</DialogClose>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" className="shrink-0" loading={saveLoading}>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
@@ -67,3 +59,4 @@ export function RenameConversationDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default React.memo(RenameModal)
|
||||
|
||||
@@ -5,7 +5,7 @@ import Chat from '../index'
|
||||
|
||||
// Keep the Chat/context/Operation/Log capability path real while omitting rich answer rendering.
|
||||
vi.mock('../answer', async () => {
|
||||
const { Operation } = await import('../answer/operation')
|
||||
const { default: Operation } = await import('../answer/operation')
|
||||
return {
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<Operation
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { ChatItem } from '../../../types'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { ChatContextProvider } from '../../context-provider'
|
||||
import { Operation } from '../operation'
|
||||
import Operation from '../operation'
|
||||
|
||||
const feedback = vi.hoisted(() => ({ onFeedback: vi.fn() }))
|
||||
const feedback = vi.hoisted(() => ({ admin: false, onFeedback: vi.fn() }))
|
||||
|
||||
vi.mock('../../context', () => ({
|
||||
useChatContext: () => ({
|
||||
config: { supportFeedback: true, supportAnnotation: feedback.admin },
|
||||
onFeedback: feedback.onFeedback,
|
||||
}),
|
||||
}))
|
||||
|
||||
// These independently owned features do not participate in feedback focus restoration.
|
||||
vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({ default: () => null }))
|
||||
@@ -18,51 +23,6 @@ vi.mock('@/app/components/base/chat/chat/log', () => ({ default: () => null }))
|
||||
|
||||
const item: ChatItem = { id: 'answer', content: 'An answer', isAnswer: true }
|
||||
|
||||
function FeedbackAnswer({ admin = false }: { admin?: boolean }) {
|
||||
return (
|
||||
<ChatContextProvider
|
||||
chatList={[item]}
|
||||
config={{
|
||||
opening_statement: '',
|
||||
pre_prompt: '',
|
||||
prompt_type: PromptMode.simple,
|
||||
user_input_form: [],
|
||||
more_like_this: { enabled: false },
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
agent_mode: { enabled: false, tools: [] },
|
||||
dataset_configs: {
|
||||
retrieval_model: 'single',
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: null,
|
||||
datasets: { datasets: [] },
|
||||
},
|
||||
supportFeedback: true,
|
||||
supportAnnotation: admin,
|
||||
}}
|
||||
onFeedback={feedback.onFeedback}
|
||||
>
|
||||
<button type="button">Before answer</button>
|
||||
<article aria-label="Answer" className="group relative m-10 h-32 w-96">
|
||||
<p>{item.content}</p>
|
||||
<Operation
|
||||
item={item}
|
||||
question="Question"
|
||||
index={0}
|
||||
maxSize={500}
|
||||
contentWidth={100}
|
||||
hasWorkflowProcess={false}
|
||||
/>
|
||||
</article>
|
||||
</ChatContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The exit transition is rendered by Chromium; the default test setup disables it.
|
||||
it.each(['cancel', 'submit'])(
|
||||
'preserves the feedback draft during the %s exit animation',
|
||||
@@ -70,9 +30,22 @@ it.each(['cancel', 'submit'])(
|
||||
const settings = globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean }
|
||||
const animationsDisabled = settings.BASE_UI_ANIMATIONS_DISABLED
|
||||
settings.BASE_UI_ANIMATIONS_DISABLED = false
|
||||
feedback.admin = false
|
||||
feedback.onFeedback.mockReset().mockResolvedValue(undefined)
|
||||
try {
|
||||
const screen = await render(<FeedbackAnswer />)
|
||||
const screen = await render(
|
||||
<article aria-label="Answer" className="group relative m-10 h-32 w-96">
|
||||
<p>{item.content}</p>
|
||||
<Operation
|
||||
item={item}
|
||||
question="Question"
|
||||
index={0}
|
||||
maxSize={500}
|
||||
contentWidth={100}
|
||||
hasWorkflowProcess={false}
|
||||
/>
|
||||
</article>,
|
||||
)
|
||||
await screen.getByRole('article', { name: 'Answer' }).hover()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'appLog.table.header.userRate: appLog.detail.operation.dislike',
|
||||
@@ -127,7 +100,23 @@ describe('Feedback dialog focus', () => {
|
||||
])(
|
||||
'returns to the visible feedback button after $close (admin: $admin)',
|
||||
async ({ admin, close }) => {
|
||||
const screen = await render(<FeedbackAnswer admin={admin} />)
|
||||
feedback.admin = admin
|
||||
const screen = await render(
|
||||
<>
|
||||
<button type="button">Before answer</button>
|
||||
<article aria-label="Answer" className="group relative m-10 h-32 w-96">
|
||||
<p>{item.content}</p>
|
||||
<Operation
|
||||
item={item}
|
||||
question="Question"
|
||||
index={0}
|
||||
maxSize={500}
|
||||
contentWidth={100}
|
||||
hasWorkflowProcess={false}
|
||||
/>
|
||||
</article>
|
||||
</>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: `${admin ? 'appLog.table.header.adminRate' : 'appLog.table.header.userRate'}: appLog.detail.operation.dislike`,
|
||||
})
|
||||
@@ -162,77 +151,3 @@ describe('Feedback dialog focus', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['false', 'reject'] as const)(
|
||||
'keeps the pending feedback focused and retries the draft after a %s failure',
|
||||
async (failure) => {
|
||||
let resolveFeedback!: (value: boolean) => void
|
||||
let rejectFeedback!: (reason: Error) => void
|
||||
feedback.onFeedback
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve, reject) => {
|
||||
resolveFeedback = resolve
|
||||
rejectFeedback = reject
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined)
|
||||
const screen = await render(<FeedbackAnswer />)
|
||||
await screen.getByRole('article', { name: 'Answer' }).hover()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'appLog.table.header.userRate: appLog.detail.operation.dislike',
|
||||
})
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: 'common.feedback.title' })
|
||||
const textbox = dialog.getByRole('textbox', { name: 'common.feedback.content' })
|
||||
await textbox.fill('Please explain')
|
||||
await userEvent.keyboard('{End}{Enter}the answer')
|
||||
const draft = 'Please explain\nthe answer'
|
||||
await expect.element(textbox).toHaveValue(draft)
|
||||
expect(feedback.onFeedback).not.toHaveBeenCalled()
|
||||
|
||||
const submit = dialog.getByRole('button', { name: 'common.operation.submit' })
|
||||
await submit.click()
|
||||
await expect.element(submit).toHaveFocus()
|
||||
await expect.element(submit).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(textbox).toHaveAttribute('readonly')
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.close' }))
|
||||
.toBeDisabled()
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
.toBeDisabled()
|
||||
await userEvent.keyboard('{Enter}{Enter}{Escape}')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
expect(feedback.onFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(feedback.onFeedback).toHaveBeenCalledWith('answer', {
|
||||
rating: 'dislike',
|
||||
content: draft,
|
||||
})
|
||||
|
||||
const outside = document.elementFromPoint(4, 4)!
|
||||
await userEvent.click(outside, { position: { x: 4, y: 4 } })
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await userEvent.tab()
|
||||
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
|
||||
await textbox.click()
|
||||
await userEvent.keyboard('ignored')
|
||||
await expect.element(textbox).toHaveValue(draft)
|
||||
|
||||
if (failure === 'false') resolveFeedback(false)
|
||||
else rejectFeedback(new Error('Feedback request failed'))
|
||||
await expect.element(textbox).not.toHaveAttribute('readonly')
|
||||
await expect.element(submit).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await expect.element(textbox).toHaveValue(draft)
|
||||
await submit.click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await expect.element(trigger).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(feedback.onFeedback).toHaveBeenCalledTimes(2)
|
||||
expect(feedback.onFeedback).toHaveBeenLastCalledWith('answer', {
|
||||
rating: 'dislike',
|
||||
content: draft,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ChatConfig, ChatItem } from '../../../types'
|
||||
import type { ChatContextValue } from '../../context'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { ChatContextProvider } from '../../context-provider'
|
||||
import { Operation } from '../operation'
|
||||
import Operation from '../operation'
|
||||
|
||||
const { mockSetShowAnnotationFullModal, mockT, mockAddAnnotation } = vi.hoisted(() => {
|
||||
return {
|
||||
@@ -152,6 +151,10 @@ const mockContextValue: ChatContextValue = {
|
||||
readonly: false,
|
||||
}
|
||||
|
||||
vi.mock('../../context', () => ({
|
||||
useChatContext: () => mockContextValue,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
@@ -216,11 +219,6 @@ describe('Operation', () => {
|
||||
<div className="group">
|
||||
<Operation {...props} />
|
||||
</div>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ChatContextProvider {...mockContextValue}>{children}</ChatContextProvider>
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,77 +237,6 @@ describe('Operation', () => {
|
||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||
})
|
||||
|
||||
describe('feedback submission sessions', () => {
|
||||
it.each([
|
||||
{ admin: false, failure: 'false' },
|
||||
{ admin: true, failure: 'false' },
|
||||
{ admin: false, failure: 'rejection' },
|
||||
{ admin: true, failure: 'rejection' },
|
||||
])(
|
||||
'preserves the draft after $failure and retries successfully (admin: $admin)',
|
||||
async ({ admin, failure }) => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportFeedback: true,
|
||||
supportAnnotation: admin,
|
||||
})
|
||||
const onFeedback = vi.fn<NonNullable<ChatContextValue['onFeedback']>>()
|
||||
if (failure === 'false') onFeedback.mockResolvedValueOnce(false)
|
||||
else onFeedback.mockRejectedValueOnce(new Error('Feedback failed'))
|
||||
onFeedback.mockResolvedValueOnce(true)
|
||||
mockContextValue.onFeedback = onFeedback
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const name = `${admin ? 'table.header.adminRate' : 'table.header.userRate'}: detail.operation.dislike`
|
||||
await user.click(screen.getByRole('button', { name }))
|
||||
const input = screen.getByRole('textbox', { name: 'feedback.content' })
|
||||
await user.type(input, 'Needs more detail')
|
||||
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
|
||||
await waitFor(() => expect(input).not.toHaveAttribute('readonly'))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(input).toHaveValue('Needs more detail')
|
||||
await user.type(input, ' please')
|
||||
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(onFeedback).toHaveBeenLastCalledWith('msg-1', {
|
||||
rating: 'dislike',
|
||||
content: 'Needs more detail please',
|
||||
})
|
||||
expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'true')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps one pending submission open and prevents edits or duplicate submissions', async () => {
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const onFeedback = vi.fn().mockReturnValue(pending)
|
||||
mockContextValue.onFeedback = onFeedback
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'table.header.userRate: detail.operation.dislike' }),
|
||||
)
|
||||
const input = screen.getByRole('textbox', { name: 'feedback.content' })
|
||||
await user.type(input, 'Original draft')
|
||||
const submit = screen.getByRole('button', { name: 'operation.submit' })
|
||||
await user.click(submit)
|
||||
expect(submit).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'operation.cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'operation.close' }))
|
||||
await user.click(submit)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(onFeedback).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
resolve()
|
||||
await pending
|
||||
})
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should hide action buttons for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user