mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
e48f13f173
22
.github/workflows/db-migration-test.yml
vendored
22
.github/workflows/db-migration-test.yml
vendored
@ -110,6 +110,28 @@ jobs:
|
||||
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
|
||||
|
||||
# hoverkraft-tech/compose-action@v2.6.0 only waits for `docker compose up -d`
|
||||
# to return (container processes started); it does not wait on healthcheck
|
||||
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
|
||||
# wait the migration runs while InnoDB is still initialising and gets
|
||||
# killed with "Lost connection during query". Poll a real SELECT until it
|
||||
# succeeds.
|
||||
- name: Wait for MySQL to accept queries
|
||||
run: |
|
||||
set +e
|
||||
for i in $(seq 1 60); do
|
||||
if docker run --rm --network host mysql:8.0 \
|
||||
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
|
||||
-e 'SELECT 1' >/dev/null 2>&1; then
|
||||
echo "MySQL ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "MySQL not ready after 60s; dumping container logs:"
|
||||
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
|
||||
exit 1
|
||||
|
||||
- name: Run DB Migration
|
||||
env:
|
||||
DEBUG: true
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -13,7 +13,7 @@ concurrency:
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
@ -468,15 +468,98 @@ class DocumentAddByFileApi(DatasetApiResource):
|
||||
return documents_and_batch_fields, 200
|
||||
|
||||
|
||||
def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
|
||||
"""Update a document from an uploaded file for canonical and deprecated routes."""
|
||||
dataset_id_str = str(dataset_id)
|
||||
tenant_id_str = str(tenant_id)
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if dataset.provider == "external":
|
||||
raise ValueError("External datasets are not supported.")
|
||||
|
||||
args: dict[str, object] = {}
|
||||
if "data" in request.form:
|
||||
args = json.loads(request.form["data"])
|
||||
if "doc_form" not in args:
|
||||
args["doc_form"] = dataset.chunk_structure or "text_model"
|
||||
if "doc_language" not in args:
|
||||
args["doc_language"] = "English"
|
||||
|
||||
# indexing_technique is already set in dataset since this is an update
|
||||
args["indexing_technique"] = dataset.indexing_technique
|
||||
|
||||
if "file" in request.files:
|
||||
# save file info
|
||||
file = request.files["file"]
|
||||
|
||||
if len(request.files) > 1:
|
||||
raise TooManyFilesError()
|
||||
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
source="datasets",
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
|
||||
# validate args
|
||||
args["original_document_id"] = str(document_id)
|
||||
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
try:
|
||||
documents, _ = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=dataset.created_by_account,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
|
||||
return documents_and_batch_fields, 200
|
||||
|
||||
|
||||
@service_api_ns.route(
|
||||
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_file",
|
||||
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-file",
|
||||
)
|
||||
class DocumentUpdateByFileApi(DatasetApiResource):
|
||||
"""Resource for update documents."""
|
||||
class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
|
||||
"""Deprecated resource aliases for file document updates."""
|
||||
|
||||
@service_api_ns.doc("update_document_by_file")
|
||||
@service_api_ns.doc(description="Update an existing document by uploading a file")
|
||||
@service_api_ns.doc("update_document_by_file_deprecated")
|
||||
@service_api_ns.doc(deprecated=True)
|
||||
@service_api_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for updating an existing document by uploading a file. "
|
||||
"Use PATCH /datasets/{dataset_id}/documents/{document_id} instead."
|
||||
)
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
@ -487,82 +570,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
|
||||
)
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def post(self, tenant_id, dataset_id, document_id):
|
||||
"""Update document by upload file."""
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if dataset.provider == "external":
|
||||
raise ValueError("External datasets are not supported.")
|
||||
|
||||
args = {}
|
||||
if "data" in request.form:
|
||||
args = json.loads(request.form["data"])
|
||||
if "doc_form" not in args:
|
||||
args["doc_form"] = dataset.chunk_structure or "text_model"
|
||||
if "doc_language" not in args:
|
||||
args["doc_language"] = "English"
|
||||
|
||||
# get dataset info
|
||||
dataset_id = str(dataset_id)
|
||||
tenant_id = str(tenant_id)
|
||||
|
||||
# indexing_technique is already set in dataset since this is an update
|
||||
args["indexing_technique"] = dataset.indexing_technique
|
||||
|
||||
if "file" in request.files:
|
||||
# save file info
|
||||
file = request.files["file"]
|
||||
|
||||
if len(request.files) > 1:
|
||||
raise TooManyFilesError()
|
||||
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
source="datasets",
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
# validate args
|
||||
args["original_document_id"] = str(document_id)
|
||||
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
try:
|
||||
documents, _ = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=dataset.created_by_account,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
|
||||
return documents_and_batch_fields, 200
|
||||
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""Update document by file through the deprecated file-update aliases."""
|
||||
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents")
|
||||
@ -876,6 +886,22 @@ class DocumentApi(DatasetApiResource):
|
||||
|
||||
return response
|
||||
|
||||
@service_api_ns.doc("update_document_by_file")
|
||||
@service_api_ns.doc(description="Update an existing document by uploading a file")
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document updated successfully",
|
||||
401: "Unauthorized - invalid API token",
|
||||
404: "Document not found",
|
||||
}
|
||||
)
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""Update document by file on the canonical document resource."""
|
||||
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
|
||||
|
||||
@service_api_ns.doc("delete_document")
|
||||
@service_api_ns.doc(description="Delete a document")
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
|
||||
@ -23,6 +23,7 @@ from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.service_api.dataset.document import (
|
||||
DeprecatedDocumentAddByTextApi,
|
||||
DeprecatedDocumentUpdateByFileApi,
|
||||
DeprecatedDocumentUpdateByTextApi,
|
||||
DocumentAddByFileApi,
|
||||
DocumentAddByTextApi,
|
||||
@ -32,7 +33,6 @@ from controllers.service_api.dataset.document import (
|
||||
DocumentListQuery,
|
||||
DocumentTextCreatePayload,
|
||||
DocumentTextUpdate,
|
||||
DocumentUpdateByFileApi,
|
||||
DocumentUpdateByTextApi,
|
||||
InvalidMetadataError,
|
||||
)
|
||||
@ -1095,8 +1095,8 @@ class TestArchivedDocumentImmutableError:
|
||||
assert error.code == 403
|
||||
|
||||
|
||||
class TestDocumentTextRouteDeprecation:
|
||||
"""Test that legacy underscore text routes stay marked deprecated."""
|
||||
class TestDocumentRouteDeprecation:
|
||||
"""Test that legacy document routes stay marked deprecated."""
|
||||
|
||||
def test_create_by_text_legacy_alias_is_deprecated(self):
|
||||
"""Ensure only the legacy create-by-text alias is marked deprecated."""
|
||||
@ -1108,10 +1108,15 @@ class TestDocumentTextRouteDeprecation:
|
||||
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
|
||||
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
|
||||
|
||||
def test_update_by_file_legacy_aliases_are_deprecated(self):
|
||||
"""Ensure only the legacy file-update aliases are marked deprecated."""
|
||||
assert DeprecatedDocumentUpdateByFileApi.post.__apidoc__["deprecated"] is True
|
||||
assert DocumentApi.patch.__apidoc__.get("deprecated") is not True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
|
||||
# DocumentUpdateByFileApi.
|
||||
# and the canonical/deprecated document file update routes.
|
||||
#
|
||||
# These controllers use ``@cloud_edition_billing_resource_check`` (does NOT
|
||||
# preserve ``__wrapped__``) and ``@cloud_edition_billing_rate_limit_check``
|
||||
@ -1359,13 +1364,52 @@ class TestDocumentAddByFileApiPost:
|
||||
api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id)
|
||||
|
||||
|
||||
class TestDocumentUpdateByFileApiPost:
|
||||
"""Test suite for DocumentUpdateByFileApi.post() endpoint.
|
||||
class TestDocumentUpdateByFileApiPatch:
|
||||
"""Test suite for the canonical document file update endpoint.
|
||||
|
||||
``post`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
``patch`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
``@cloud_edition_billing_rate_limit_check``.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("route_name", ["update_by_file", "update-by-file"])
|
||||
@patch("controllers.service_api.dataset.document._update_document_by_file")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_file_deprecated_aliases_delegate_to_shared_handler(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_update_document_by_file,
|
||||
route_name,
|
||||
app,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
"""Test legacy POST aliases still dispatch while marked deprecated."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
|
||||
mock_update_document_by_file.return_value = ({"document": {"id": "doc-1"}, "batch": "batch-1"}, 200)
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/{route_name}",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DeprecatedDocumentUpdateByFileApi()
|
||||
response, status = api.post(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert response["batch"] == "batch-1"
|
||||
mock_update_document_by_file.assert_called_once_with(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@ -1387,15 +1431,15 @@ class TestDocumentUpdateByFileApiPost:
|
||||
doc_id = str(uuid.uuid4())
|
||||
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
|
||||
method="POST",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
|
||||
method="PATCH",
|
||||
content_type="multipart/form-data",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DocumentUpdateByFileApi()
|
||||
api = DocumentApi()
|
||||
with pytest.raises(ValueError, match="Dataset does not exist"):
|
||||
api.post(
|
||||
api.patch(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
@ -1423,15 +1467,15 @@ class TestDocumentUpdateByFileApiPost:
|
||||
doc_id = str(uuid.uuid4())
|
||||
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
|
||||
method="POST",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
|
||||
method="PATCH",
|
||||
content_type="multipart/form-data",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DocumentUpdateByFileApi()
|
||||
api = DocumentApi()
|
||||
with pytest.raises(ValueError, match="External datasets"):
|
||||
api.post(
|
||||
api.patch(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
@ -1482,14 +1526,14 @@ class TestDocumentUpdateByFileApiPost:
|
||||
doc_id = str(uuid.uuid4())
|
||||
data = {"file": (BytesIO(b"file content"), "test.pdf", "application/pdf")}
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
|
||||
method="POST",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
|
||||
method="PATCH",
|
||||
content_type="multipart/form-data",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DocumentUpdateByFileApi()
|
||||
response, status = api.post(
|
||||
api = DocumentApi()
|
||||
response, status = api.patch(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
|
||||
@ -5,12 +5,13 @@ from baidubce.auth.bce_credentials import BceCredentials
|
||||
from baidubce.bce_client_configuration import BceClientConfiguration
|
||||
|
||||
from extensions.storage.baidu_obs_storage import BaiduObsStorage
|
||||
from tests.unit_tests.oss.__mock.baidu_obs import setup_baidu_obs_mock
|
||||
from tests.unit_tests.oss.__mock.base import (
|
||||
BaseStorageTest,
|
||||
get_example_bucket,
|
||||
)
|
||||
|
||||
pytest_plugins = ("tests.unit_tests.oss.__mock.baidu_obs",)
|
||||
|
||||
|
||||
class TestBaiduObs(BaseStorageTest):
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@ -59,19 +59,25 @@ services:
|
||||
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
|
||||
ports:
|
||||
- "${EXPOSE_MYSQL_PORT:-3306}:3306"
|
||||
# mysqladmin ping passes during mysql:8.0's TCP-listening stage even while
|
||||
# the server is still finalising init, leading to "Lost connection during
|
||||
# query" on the first real query. Verify with a real SELECT instead.
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"mysqladmin",
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"mysql",
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-uroot",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
"-e",
|
||||
"SELECT 1",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
|
||||
# The redis cache.
|
||||
redis:
|
||||
|
||||
@ -17,3 +17,10 @@ Feature: Share app publicly
|
||||
Given a workflow app has been published and shared via API
|
||||
When I open the shared app URL
|
||||
Then the shared app page should be accessible
|
||||
|
||||
@unauthenticated
|
||||
Scenario: Run a shared workflow app without authentication
|
||||
Given a workflow app has been published and shared via API
|
||||
When I open the shared app URL
|
||||
And I run the shared workflow app
|
||||
Then the shared workflow run should succeed
|
||||
|
||||
@ -37,3 +37,15 @@ Then('the shared app page should be accessible', async function (this: DifyWorld
|
||||
await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 })
|
||||
await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
When('I run the shared workflow app', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const runButton = page.getByTestId('run-button')
|
||||
|
||||
await expect(runButton).toBeEnabled({ timeout: 15_000 })
|
||||
await runButton.click()
|
||||
})
|
||||
|
||||
Then('the shared workflow run should succeed', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByTestId('status-icon-success')).toBeVisible({ timeout: 55_000 })
|
||||
})
|
||||
|
||||
@ -12,8 +12,10 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
|
||||
|
||||
When('I run the workflow', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('Test Run').click()
|
||||
await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 })
|
||||
const testRunButton = page.getByText('Test Run')
|
||||
|
||||
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
|
||||
await testRunButton.click()
|
||||
})
|
||||
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
|
||||
@ -3506,11 +3506,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
|
||||
@ -532,6 +532,7 @@ describe('useEmbeddedChatbot', () => {
|
||||
})
|
||||
|
||||
it('handleChangeConversation updates current conversation and refetches chat list', async () => {
|
||||
mockStoreState.embeddedConversationId = null
|
||||
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
|
||||
|
||||
act(() => {
|
||||
@ -548,6 +549,39 @@ describe('useEmbeddedChatbot', () => {
|
||||
expect(result.current.clearChatList).toBe(false)
|
||||
})
|
||||
|
||||
// Scenario: URL-provided conversation_id should take precedence over localStorage value.
|
||||
it('should prioritize URL conversation_id over localStorage', async () => {
|
||||
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({
|
||||
'app-1': { 'embedded-user-1': 'stored-conv-id' },
|
||||
}))
|
||||
mockStoreState.embeddedConversationId = 'url-conv-id'
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({
|
||||
user_id: 'embedded-user-1',
|
||||
conversation_id: 'url-conv-id',
|
||||
})
|
||||
|
||||
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentConversationId).toBe('url-conv-id')
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: When no URL conversation_id is provided, fall back to localStorage.
|
||||
it('should fall back to localStorage when no URL conversation_id is provided', async () => {
|
||||
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({
|
||||
'app-1': { DEFAULT: 'stored-conv-id' },
|
||||
}))
|
||||
mockStoreState.embeddedConversationId = null
|
||||
mockStoreState.embeddedUserId = null
|
||||
|
||||
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentConversationId).toBe('stored-conv-id')
|
||||
})
|
||||
})
|
||||
|
||||
it('handleFeedback invokes updateFeedback service successfully', async () => {
|
||||
const { updateFeedback } = await import('@/service/share')
|
||||
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
|
||||
|
||||
@ -113,7 +113,7 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
|
||||
})
|
||||
}, [setConversationIdInfo])
|
||||
const allowResetChat = !conversationId
|
||||
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '', [appId, conversationIdInfo, userId, conversationId])
|
||||
const currentConversationId = useMemo(() => conversationId || conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId, conversationId])
|
||||
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
|
||||
if (appId) {
|
||||
let prevValue = conversationIdInfo?.[appId || '']
|
||||
|
||||
@ -43,7 +43,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
@ -54,7 +54,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
})
|
||||
@ -100,7 +100,7 @@ describe('OptionListItem', () => {
|
||||
Clickable
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -111,7 +111,7 @@ describe('OptionListItem', () => {
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
|
||||
})
|
||||
@ -126,7 +126,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import OptionList from '../option-list'
|
||||
|
||||
describe('OptionList', () => {
|
||||
it('should render a scrollable list with hidden scrollbar styles', () => {
|
||||
render(
|
||||
<OptionList>
|
||||
<li>Item</li>
|
||||
</OptionList>,
|
||||
)
|
||||
|
||||
const list = screen.getByRole('list')
|
||||
|
||||
expect(list).toHaveClass('overflow-y-auto')
|
||||
expect(list).toHaveClass('[scrollbar-width:none]')
|
||||
expect(list).toHaveClass('[&::-webkit-scrollbar]:hidden')
|
||||
})
|
||||
|
||||
it('should append caller className after default classes', () => {
|
||||
render(
|
||||
<OptionList className="custom-list">
|
||||
<li>Item</li>
|
||||
</OptionList>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('list')).toHaveClass('custom-list')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@ -7,7 +7,8 @@ type OptionListItemProps = {
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
noAutoScroll?: boolean
|
||||
} & React.LiHTMLAttributes<HTMLLIElement>
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const OptionListItem: FC<OptionListItemProps> = ({
|
||||
isSelected,
|
||||
@ -25,16 +26,21 @@ const OptionListItem: FC<OptionListItemProps> = ({
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text outline-hidden',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
|
||||
type OptionListProps = {
|
||||
children: ReactNode
|
||||
} & HTMLAttributes<HTMLUListElement>
|
||||
|
||||
const optionListClassName = cn(
|
||||
'flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]',
|
||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
)
|
||||
|
||||
const OptionList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: OptionListProps) => {
|
||||
return (
|
||||
<ul className={cn(optionListClassName, className)} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(OptionList)
|
||||
@ -64,13 +64,13 @@ describe('TimePickerOptions', () => {
|
||||
it('should render selected hour in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
|
||||
const selectedHour = screen.getAllByRole('button').find(item => item.textContent === '05')
|
||||
expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
it('should render selected minute in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
|
||||
const selectedMinute = screen.getAllByRole('button').find(item => item.textContent === '30')
|
||||
expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { TimeOptionsProps } from '../types'
|
||||
import * as React from 'react'
|
||||
import OptionList from '../common/option-list'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
import { useTimeOptions } from '../hooks'
|
||||
|
||||
@ -16,7 +17,7 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-1 p-2">
|
||||
{/* Hour */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
hourOptions.map((hour) => {
|
||||
const isSelected = selectedTime?.format('hh') === hour
|
||||
@ -31,9 +32,9 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Minute */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
|
||||
const isSelected = selectedTime?.format('mm') === minute
|
||||
@ -48,9 +49,9 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Period */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
periodOptions.map((period) => {
|
||||
const isSelected = selectedTime?.format('A') === period
|
||||
@ -66,7 +67,7 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Placement } from '@floating-ui/react'
|
||||
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
export enum ViewType {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { YearAndMonthPickerOptionsProps } from '../types'
|
||||
import * as React from 'react'
|
||||
import OptionList from '../common/option-list'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
import { useMonths, useYearOptions } from '../hooks'
|
||||
|
||||
@ -16,7 +17,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-1 p-2">
|
||||
{/* Month Picker */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
months.map((month, index) => {
|
||||
const isSelected = selectedMonth === index
|
||||
@ -31,9 +32,9 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Year Picker */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
yearOptions.map((year) => {
|
||||
const isSelected = selectedYear === year
|
||||
@ -48,7 +49,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-max"
|
||||
listClassName="no-scrollbar"
|
||||
>
|
||||
{supportedLanguages.map(({ prompt_name }) => (
|
||||
<SelectItem key={prompt_name} value={prompt_name}>
|
||||
|
||||
@ -55,7 +55,14 @@ vi.mock('../../hooks', async () => {
|
||||
})
|
||||
|
||||
vi.mock('../popup-item', () => ({
|
||||
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
|
||||
default: ({ model }: { model: Model }) => (
|
||||
<div>
|
||||
<span>{model.provider}</span>
|
||||
{model.models.map(modelItem => (
|
||||
<span key={modelItem.model}>{modelItem.model}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
@ -207,6 +214,156 @@ describe('Popup', () => {
|
||||
expect((input as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('should show matching models when searching by model name', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })],
|
||||
}),
|
||||
makeModel({
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
|
||||
models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'claude' } },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude-3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No model found for \u201Cclaude\u201D')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty search placeholder when no provider or model name matches', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'mistral' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('No model found for \u201Cmistral\u201D'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all models of a provider when searching by provider label', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
|
||||
makeModelItem({ model: 'gpt-4o', label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' } }),
|
||||
],
|
||||
}),
|
||||
makeModel({
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
|
||||
models: [
|
||||
makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'openai' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('openai'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4o'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('claude-3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match by model provider key when model label does not contain the search text', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
provider: 'azure_openai',
|
||||
label: { en_US: 'Azure', zh_Hans: 'Azure' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'openai' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('azure_openai'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still apply scope features when matching by provider label', () => {
|
||||
mockSupportFunctionCall.mockReturnValue(false)
|
||||
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', features: [ModelFeatureEnum.vision] }),
|
||||
makeModelItem({ model: 'gpt-4-tool', features: [ModelFeatureEnum.toolCall] }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
scopeFeatures={[ModelFeatureEnum.toolCall]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'openai' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('No model found for \u201Copenai\u201D'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show compatible-only helper text when no scope features are applied', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
@ -219,8 +376,8 @@ describe('Popup', () => {
|
||||
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show compatible-only helper banner when scope features are applied', () => {
|
||||
const { container } = renderPopup(
|
||||
it('should show compatible-only helper text when scope features are applied', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
@ -231,7 +388,26 @@ describe('Popup', () => {
|
||||
|
||||
expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-information-2-fill'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep search and footer outside the scrollable model list', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
scopeFeatures={[ModelFeatureEnum.vision]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' })
|
||||
const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||
const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ })
|
||||
|
||||
expect(scrollRegion)!.toBeInTheDocument()
|
||||
expect(scrollRegion).not.toContainElement(searchInput)
|
||||
expect(scrollRegion).not.toContainElement(settingsButton)
|
||||
expect(scrollRegion).toContainElement(screen.getByTestId('compatible-models-banner'))
|
||||
})
|
||||
|
||||
it('should filter by scope features including toolCall and non-toolCall checks', () => {
|
||||
|
||||
@ -88,7 +88,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
className={popupClassName}
|
||||
popupClassName="overflow-hidden rounded-lg"
|
||||
popupClassName="overflow-hidden rounded-xl"
|
||||
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
|
||||
>
|
||||
<Popup
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { modelNameMap, providerIconMap } from '../utils'
|
||||
|
||||
type MarketplaceSectionProps = {
|
||||
marketplaceProviders: ModelProviderQuotaGetPaid[]
|
||||
marketplaceCollapsed: boolean
|
||||
installingProvider: ModelProviderQuotaGetPaid | null
|
||||
isMarketplacePluginsLoading: boolean
|
||||
theme?: string
|
||||
onMarketplaceCollapsedChange: (collapsed: boolean) => void
|
||||
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
|
||||
}
|
||||
|
||||
const MarketplaceSection: FC<MarketplaceSectionProps> = ({
|
||||
marketplaceProviders,
|
||||
marketplaceCollapsed,
|
||||
installingProvider,
|
||||
isMarketplacePluginsLoading,
|
||||
theme,
|
||||
onMarketplaceCollapsedChange,
|
||||
onInstallPlugin,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (marketplaceProviders.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-2">
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex h-[22px] items-center pr-2 pl-4">
|
||||
<div
|
||||
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
|
||||
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
|
||||
>
|
||||
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
|
||||
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
{!marketplaceCollapsed && (
|
||||
<div className="px-1 pb-1">
|
||||
{marketplaceProviders.map((key) => {
|
||||
const Icon = providerIconMap[key]
|
||||
const isInstalling = installingProvider === key
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 py-0.5">
|
||||
<Icon className="h-5 w-5 shrink-0 rounded-md" />
|
||||
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={cn(
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
onClick={() => onInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
||||
{isInstalling
|
||||
? t('installModal.installing', { ns: 'plugin' })
|
||||
: t('modelProvider.selector.install', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
|
||||
href={getMarketplaceUrl('', { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarketplaceSection
|
||||
@ -0,0 +1,39 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ModelSelectorEmptyStateProps = {
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
const ModelSelectorEmptyState: FC<ModelSelectorEmptyStateProps> = ({
|
||||
onConfigure,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mx-2 flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
|
||||
</p>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[108px]"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t('modelProvider.selector.configure', { ns: 'common' })}
|
||||
<span className="i-ri-arrow-right-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelSelectorEmptyState
|
||||
@ -107,7 +107,8 @@ const PopupItem: FC<PopupItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div className="sticky top-12 z-2 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
{/* Keep the sticky provider header above model rows while the list scrolls. */}
|
||||
<div className="sticky top-0 z-1 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => setCollapsed(prev => !prev)}
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ModelSelectorPopupFrameProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorPopupFrame: FC<ModelSelectorPopupFrameProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelSelectorSearchHeaderProps = {
|
||||
searchText: string
|
||||
onSearchTextChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const ModelSelectorSearchHeader: FC<ModelSelectorSearchHeaderProps> = ({
|
||||
searchText,
|
||||
onSearchTextChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
|
||||
<div className={`
|
||||
flex h-8 items-center rounded-lg border px-2
|
||||
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
mr-0.5 i-ri-search-line h-4 w-4 shrink-0
|
||||
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
|
||||
`}
|
||||
/>
|
||||
<input
|
||||
className="block h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-hidden"
|
||||
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
|
||||
value={searchText}
|
||||
onChange={e => onSearchTextChange(e.target.value)}
|
||||
/>
|
||||
{
|
||||
searchText && (
|
||||
<span
|
||||
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
|
||||
onClick={() => onSearchTextChange('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelSelectorScrollBodyProps = {
|
||||
children: ReactNode
|
||||
label: string
|
||||
}
|
||||
|
||||
export const ModelSelectorScrollBody: FC<ModelSelectorScrollBodyProps> = ({
|
||||
children,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<ScrollAreaRoot className="relative min-h-0 overflow-hidden overscroll-contain">
|
||||
<ScrollAreaViewport
|
||||
aria-label={label}
|
||||
className="max-h-[calc(min(624px,var(--available-height,624px))-84px)] overscroll-contain"
|
||||
role="region"
|
||||
>
|
||||
<ScrollAreaContent className="min-w-0">
|
||||
{children}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
{/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */}
|
||||
<ScrollAreaScrollbar className="z-2 data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const CompatibleModelsNotice = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="compatible-models-banner"
|
||||
className="px-4 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelProviderSettingsFooterProps = {
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export const ModelProviderSettingsFooter: FC<ModelProviderSettingsFooterProps> = ({
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-divider-subtle p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
||||
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,8 +5,6 @@ import type {
|
||||
ModelItem,
|
||||
} from '../declarations'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import { supportFunctionCall } from '@/utils/tool-call'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelFeatureEnum,
|
||||
@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
|
||||
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
|
||||
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
|
||||
import { providerSupportsCredits } from '../supports-credits'
|
||||
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
|
||||
import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
|
||||
import MarketplaceSection from './marketplace-section'
|
||||
import ModelSelectorEmptyState from './popup-empty-state'
|
||||
import PopupItem from './popup-item'
|
||||
import {
|
||||
CompatibleModelsNotice,
|
||||
ModelProviderSettingsFooter,
|
||||
ModelSelectorPopupFrame,
|
||||
ModelSelectorScrollBody,
|
||||
ModelSelectorSearchHeader,
|
||||
} from './popup-layout'
|
||||
|
||||
type PopupProps = {
|
||||
defaultModel?: DefaultModel
|
||||
@ -137,18 +143,26 @@ const Popup: FC<PopupProps> = ({
|
||||
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
|
||||
|
||||
const filteredModelList = useMemo(() => {
|
||||
const normalizedSearch = searchText.toLowerCase()
|
||||
const matchesLabel = (label: Record<string, string>) => {
|
||||
if (label[language] !== undefined)
|
||||
return label[language].toLowerCase().includes(normalizedSearch)
|
||||
return Object.values(label).some(value =>
|
||||
value.toLowerCase().includes(normalizedSearch),
|
||||
)
|
||||
}
|
||||
|
||||
const filtered = installedModelList.map((model) => {
|
||||
const matchesProviderSearch = !searchText
|
||||
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|
||||
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const providerMatched = !!searchText && (
|
||||
matchesLabel(model.label)
|
||||
|| model.provider.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
|
||||
const filteredModels = model.models
|
||||
.filter((modelItem) => {
|
||||
if (modelItem.label[language] !== undefined)
|
||||
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
|
||||
return Object.values(modelItem.label).some(label =>
|
||||
label.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
if (!searchText || providerMatched)
|
||||
return true
|
||||
return matchesLabel(modelItem.label)
|
||||
})
|
||||
.filter((modelItem) => {
|
||||
if (scopeFeatures.length === 0)
|
||||
@ -159,8 +173,12 @@ const Popup: FC<PopupProps> = ({
|
||||
return modelItem.features?.includes(feature) ?? false
|
||||
})
|
||||
})
|
||||
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
|
||||
if (
|
||||
(searchText && filteredModels.length === 0)
|
||||
|| (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...model, models: filteredModels }
|
||||
}).filter((model): model is Model => model !== null)
|
||||
@ -181,166 +199,59 @@ const Popup: FC<PopupProps> = ({
|
||||
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
|
||||
}, [modelProviders])
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}, [onHide, setShowAccountSettingModal])
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar max-h-[480px] overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg pt-3 pr-2 pb-1 pl-3">
|
||||
<div className={`
|
||||
flex h-8 items-center rounded-lg border pr-[10px] pl-[9px]
|
||||
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
mr-[7px] i-ri-search-line h-[14px] w-[14px] shrink-0
|
||||
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
|
||||
`}
|
||||
/>
|
||||
<input
|
||||
className="block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-hidden"
|
||||
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
{
|
||||
searchText && (
|
||||
<span
|
||||
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
|
||||
onClick={() => setSearchText('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{scopeFeatures.length > 0 && (
|
||||
<div
|
||||
data-testid="compatible-models-banner"
|
||||
className="mt-2 flex items-center gap-1 rounded-lg bg-background-section-burn px-2.5 py-2"
|
||||
>
|
||||
<span className="i-ri-information-2-fill h-4 w-4 shrink-0 text-text-accent" />
|
||||
<p className="system-xs-medium text-text-secondary">
|
||||
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ModelSelectorPopupFrame>
|
||||
<ModelSelectorSearchHeader
|
||||
searchText={searchText}
|
||||
onSearchTextChange={setSearchText}
|
||||
/>
|
||||
{showCreditsExhaustedAlert && (
|
||||
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
||||
)}
|
||||
<div className="pr-1 pb-1 pl-3">
|
||||
{
|
||||
filteredModelList.map(model => (
|
||||
<PopupItem
|
||||
key={model.provider}
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
onSelect={onSelect}
|
||||
onHide={onHide}
|
||||
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
|
||||
<div className="pb-1">
|
||||
{
|
||||
filteredModelList.map(model => (
|
||||
<PopupItem
|
||||
key={model.provider}
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
onSelect={onSelect}
|
||||
onHide={onHide}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<ModelSelectorEmptyState
|
||||
onConfigure={handleOpenSettings}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<div className="flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
)}
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
|
||||
{`No model found for \u201C${searchText}\u201D`}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
|
||||
</p>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[108px]"
|
||||
onClick={() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}}
|
||||
>
|
||||
{t('modelProvider.selector.configure', { ns: 'common' })}
|
||||
<span className="i-ri-arrow-right-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
|
||||
{`No model found for \u201C${searchText}\u201D`}
|
||||
</div>
|
||||
)}
|
||||
{marketplaceProviders.length > 0 && (
|
||||
<>
|
||||
<div className="mx-2 my-1 border-t border-divider-subtle" />
|
||||
<div className="mb-1">
|
||||
<div className="flex h-[22px] items-center px-3">
|
||||
<div
|
||||
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
|
||||
onClick={() => setMarketplaceCollapsed(prev => !prev)}
|
||||
>
|
||||
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
|
||||
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
{!marketplaceCollapsed && (
|
||||
<>
|
||||
{marketplaceProviders.map((key) => {
|
||||
const Icon = providerIconMap[key]
|
||||
const isInstalling = installingProvider === key
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 py-0.5">
|
||||
<Icon className="h-5 w-5 shrink-0 rounded-md" />
|
||||
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={cn(
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
onClick={() => handleInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
||||
{isInstalling
|
||||
? t('installModal.installing', { ns: 'plugin' })
|
||||
: t('modelProvider.selector.install', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
|
||||
href={getMarketplaceUrl('', { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
||||
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scopeFeatures.length > 0 && (
|
||||
<CompatibleModelsNotice />
|
||||
)}
|
||||
<MarketplaceSection
|
||||
marketplaceProviders={marketplaceProviders}
|
||||
marketplaceCollapsed={marketplaceCollapsed}
|
||||
installingProvider={installingProvider}
|
||||
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
|
||||
theme={theme}
|
||||
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
|
||||
onInstallPlugin={handleInstallPlugin}
|
||||
/>
|
||||
</div>
|
||||
</ModelSelectorScrollBody>
|
||||
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
|
||||
</ModelSelectorPopupFrame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import PluginItem from './plugin-item'
|
||||
|
||||
type PluginSectionProps = {
|
||||
@ -43,7 +44,14 @@ const PluginSection: FC<PluginSectionProps> = ({
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<ScrollArea
|
||||
className="max-h-[300px] overflow-hidden"
|
||||
label={title}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
>
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
@ -59,7 +67,7 @@ const PluginSection: FC<PluginSectionProps> = ({
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import ErrorPluginItem from './error-plugin-item'
|
||||
@ -86,7 +87,14 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<ScrollArea
|
||||
className="max-h-[300px] overflow-hidden"
|
||||
label={t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
>
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
@ -96,7 +104,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -117,7 +117,7 @@ const PluginTasks = () => {
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="[scrollbar-width:none] overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none [&::-webkit-scrollbar]:hidden"
|
||||
popupClassName="overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<PluginTaskList
|
||||
runningPlugins={runningPlugins}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AutoUpdateConfig } from '../types'
|
||||
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
@ -803,165 +804,103 @@ describe('auto-update-setting', () => {
|
||||
})
|
||||
|
||||
describe('StrategyPicker (strategy-picker.tsx)', () => {
|
||||
const defaultProps = {
|
||||
value: AUTO_UPDATE_STRATEGY.disabled,
|
||||
onChange: vi.fn(),
|
||||
const i18nKeyByStrategy: Record<AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest'> = {
|
||||
[AUTO_UPDATE_STRATEGY.disabled]: 'disabled',
|
||||
[AUTO_UPDATE_STRATEGY.fixOnly]: 'fixOnly',
|
||||
[AUTO_UPDATE_STRATEGY.latest]: 'latest',
|
||||
}
|
||||
|
||||
const triggerName = (strategy: AUTO_UPDATE_STRATEGY) =>
|
||||
new RegExp(`plugin\\.autoUpdate\\.strategy\\.${i18nKeyByStrategy[strategy]}\\.name`, 'i')
|
||||
|
||||
const findOption = async (key: 'disabled' | 'fixOnly' | 'latest') => {
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
const option = options.find(item =>
|
||||
item.textContent?.includes(`plugin.autoUpdate.strategy.${key}.name`),
|
||||
)
|
||||
if (!option)
|
||||
throw new Error(`Strategy option "${key}" not found`)
|
||||
return option
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render trigger button with current strategy label', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all strategy options when open', () => {
|
||||
// Arrange
|
||||
mockPortalOpen = true
|
||||
it('should render all strategy options when open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }))
|
||||
|
||||
// Wait for portal to open
|
||||
if (mockPortalOpen) {
|
||||
// Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown)
|
||||
expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument()
|
||||
}
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle dropdown when trigger is clicked', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
|
||||
// Assert - initially closed
|
||||
expect(mockPortalOpen).toBe(false)
|
||||
|
||||
// Act - click trigger
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Assert - portal trigger element should still be in document
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with fixOnly when Bug Fixes Only option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Bug Fixes Only" option
|
||||
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
|
||||
expect(fixOnlyOption).toBeInTheDocument()
|
||||
fireEvent.click(fixOnlyOption!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
||||
})
|
||||
|
||||
it('should call onChange with latest when Latest Version option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Latest Version" option
|
||||
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
|
||||
expect(latestOption).toBeInTheDocument()
|
||||
fireEvent.click(latestOption!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
||||
})
|
||||
|
||||
it('should call onChange with disabled when Disabled option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Disabled" option - need to find the one in the dropdown, not the button
|
||||
const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name')
|
||||
// The second one should be in the dropdown
|
||||
const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]'))
|
||||
expect(dropdownOption).toBeInTheDocument()
|
||||
fireEvent.click(dropdownOption!.closest('div[class*="cursor-pointer"]')!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.disabled)
|
||||
})
|
||||
|
||||
it('should stop event propagation when option is clicked', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
const parentClickHandler = vi.fn()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<div onClick={parentClickHandler}>
|
||||
<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Click an option
|
||||
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(fixOnlyOption!)
|
||||
|
||||
// Assert - onChange is called but parent click handler should not propagate
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
||||
})
|
||||
|
||||
it('should render check icon for currently selected option', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
|
||||
// Act - render with fixOnly selected
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert - RiCheckLine should be rendered (check icon)
|
||||
// Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent)
|
||||
const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]'))
|
||||
const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]')
|
||||
expect(optionContainer).toBeInTheDocument()
|
||||
// The check icon SVG should exist within the option
|
||||
expect(optionContainer?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon for non-selected options', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
|
||||
// Act - render with disabled selected
|
||||
it('should open and close the menu when the trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert - check the Latest Version option should not have check icon
|
||||
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
|
||||
// The svg should only be in selected option, not in non-selected
|
||||
const checkIconContainer = latestOption?.querySelector('div.mr-1')
|
||||
// Non-selected option should have empty check icon container
|
||||
expect(checkIconContainer?.querySelector('svg')).toBeNull()
|
||||
const trigger = screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each<[AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest', AUTO_UPDATE_STRATEGY]>([
|
||||
[AUTO_UPDATE_STRATEGY.disabled, 'fixOnly', AUTO_UPDATE_STRATEGY.fixOnly],
|
||||
[AUTO_UPDATE_STRATEGY.disabled, 'latest', AUTO_UPDATE_STRATEGY.latest],
|
||||
[AUTO_UPDATE_STRATEGY.fixOnly, 'disabled', AUTO_UPDATE_STRATEGY.disabled],
|
||||
])('should call onChange with %s -> %s when option is selected', async (initial, optionKey, expected) => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<StrategyPicker value={initial} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(initial) }))
|
||||
await user.click(await findOption(optionKey))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('should mark only the currently selected option with aria-checked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
|
||||
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
const checked = options.filter(o => o.getAttribute('aria-checked') === 'true')
|
||||
|
||||
expect(checked).toHaveLength(1)
|
||||
expect(checked[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
})
|
||||
|
||||
it('should render the check indicator inside the selected option only', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
|
||||
|
||||
const fixOnlyOption = await findOption('fixOnly')
|
||||
const latestOption = await findOption('latest')
|
||||
|
||||
expect(fixOnlyOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
expect(latestOption.querySelector('.i-ri-check-line')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1280,7 +1219,9 @@ describe('auto-update-setting', () => {
|
||||
render(<AutoUpdateSetting {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show time picker when strategy is not disabled', () => {
|
||||
@ -1407,16 +1348,27 @@ describe('auto-update-setting', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated strategy when strategy changes', () => {
|
||||
it('should call onChange with updated strategy when strategy changes', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const payload = createMockAutoUpdateConfig()
|
||||
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
||||
|
||||
// Act
|
||||
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
||||
|
||||
// Assert - component renders with strategy picker
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
|
||||
)
|
||||
const latestOption = (await screen.findAllByRole('menuitemradio')).find(item =>
|
||||
item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'),
|
||||
)!
|
||||
await user.click(latestOption)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onChange with updated time when time changes', () => {
|
||||
|
||||
@ -1,62 +1,12 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import StrategyPicker from '../strategy-picker'
|
||||
import { AUTO_UPDATE_STRATEGY } from '../types'
|
||||
|
||||
let portalOpen = false
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <span data-testid="picker-button">{children}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const _React = await import('react')
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
portalOpen = open
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
|
||||
}) => (
|
||||
<button
|
||||
data-testid="trigger"
|
||||
onClick={() => onClick({
|
||||
stopPropagation: vi.fn(),
|
||||
nativeEvent: { stopImmediatePropagation: vi.fn() },
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
|
||||
}
|
||||
})
|
||||
const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i')
|
||||
|
||||
describe('StrategyPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalOpen = false
|
||||
})
|
||||
|
||||
it('renders the selected strategy label in the trigger', () => {
|
||||
render(
|
||||
<StrategyPicker
|
||||
@ -65,10 +15,12 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
expect(screen.getByRole('button', { name: triggerName('fixOnly') })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the option list when the trigger is clicked', () => {
|
||||
it('opens the option list when the trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<StrategyPicker
|
||||
value={AUTO_UPDATE_STRATEGY.disabled}
|
||||
@ -76,14 +28,33 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when a new strategy is selected', () => {
|
||||
it('marks only the currently selected strategy as checked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<StrategyPicker
|
||||
value={AUTO_UPDATE_STRATEGY.fixOnly}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName('fixOnly') }))
|
||||
|
||||
const checkedOptions = (await screen.findAllByRole('menuitemradio'))
|
||||
.filter(item => item.getAttribute('aria-checked') === 'true')
|
||||
|
||||
expect(checkedOptions).toHaveLength(1)
|
||||
expect(checkedOptions[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
})
|
||||
|
||||
it('calls onChange and closes the menu when a new strategy is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<StrategyPicker
|
||||
@ -92,9 +63,12 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
|
||||
const latestOption = (await screen.findAllByRole('menuitemradio'))
|
||||
.find(item => item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))!
|
||||
await user.click(latestOption)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
||||
expect(await screen.findByRole('button', { name: triggerName('disabled') })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -105,7 +105,7 @@ const AutoUpdateSetting: FC<Props> = ({
|
||||
const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => {
|
||||
return (
|
||||
<div
|
||||
className="group float-right flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt"
|
||||
className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex w-0 grow items-center gap-x-1">
|
||||
@ -137,7 +137,7 @@ const AutoUpdateSetting: FC<Props> = ({
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label label={t(`${i18nPrefix}.updateTime`, { ns: 'plugin' })} />
|
||||
<div className="flex flex-col justify-start">
|
||||
<div className="flex flex-col items-end">
|
||||
<TimePicker
|
||||
value={timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!))}
|
||||
timezone={timezone}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { AUTO_UPDATE_STRATEGY } from './types'
|
||||
|
||||
const i18nPrefix = 'autoUpdate.strategy'
|
||||
@ -42,58 +41,48 @@ const StrategyPicker = ({
|
||||
},
|
||||
]
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
const handleValueChange = (nextValue: string) => {
|
||||
onChange(nextValue as AUTO_UPDATE_STRATEGY)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-end"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
<DropdownMenuTrigger render={<Button size="small" />}>
|
||||
{selectedOption?.label}
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="top-end"
|
||||
sideOffset={4}
|
||||
className="z-99"
|
||||
popupClassName="w-[280px] p-1"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{selectedOption?.label}
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
onChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="mr-1 w-4 shrink-0">
|
||||
{
|
||||
value === option.value && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
|
||||
</div>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="mx-0 h-auto items-start gap-1 p-2 pr-3"
|
||||
>
|
||||
<div className="mr-1 flex w-4 shrink-0 justify-center pt-0.5">
|
||||
<DropdownMenuRadioItemIndicator className="ml-0" />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<div className="grow">
|
||||
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -49,17 +49,17 @@ export const ChecklistNodeGroup = memo(({
|
||||
<div
|
||||
key={sub.key}
|
||||
className={cn(
|
||||
'group/item flex items-center gap-2 rounded-lg px-1',
|
||||
'group/item flex items-start gap-2 rounded-lg px-1',
|
||||
goToEnabled && 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => goToEnabled && onItemClick(item)}
|
||||
>
|
||||
<ItemIndicator />
|
||||
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
|
||||
<span className="min-w-0 grow py-1 text-xs leading-4 text-text-warning">
|
||||
{sub.message}
|
||||
</span>
|
||||
{goToEnabled && (
|
||||
<div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
|
||||
<div className="flex shrink-0 items-center gap-0.5 pt-1 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
|
||||
<span className="text-xs leading-4 font-medium whitespace-nowrap text-text-accent">
|
||||
{t('panel.goToFix', { ns: 'workflow' })}
|
||||
</span>
|
||||
|
||||
@ -5,7 +5,7 @@ import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RemoveButton from '@/app/components/workflow/nodes/_base/components/remove-button'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import VariableTypeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/variable-type-select'
|
||||
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
|
||||
|
||||
@ -116,7 +116,7 @@ const ObjectValueItem: FC<Props> = ({
|
||||
{/* Value */}
|
||||
<div className="relative w-[230px]">
|
||||
<input
|
||||
className="block h-7 w-full appearance-none px-2 system-xs-regular text-text-secondary caret-primary-600 outline-hidden placeholder:system-xs-regular placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
|
||||
className="block h-7 w-full appearance-none px-2 pr-9 system-xs-regular text-text-secondary caret-primary-600 outline-hidden placeholder:system-xs-regular placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
|
||||
placeholder={t('chatVariable.modal.objectValue', { ns: 'workflow' }) || ''}
|
||||
value={list[index].value}
|
||||
onChange={handleValueChange(index)}
|
||||
@ -125,10 +125,15 @@ const ObjectValueItem: FC<Props> = ({
|
||||
type={list[index].type === ChatVarType.Number ? 'number' : 'text'}
|
||||
/>
|
||||
{list.length > 1 && !isFocus && (
|
||||
<RemoveButton
|
||||
className="absolute top-0.5 right-1 z-10 hidden group-hover:block"
|
||||
onClick={handleItemRemove(index)}
|
||||
/>
|
||||
<div className="absolute top-0.5 right-1 z-10">
|
||||
<ActionButton
|
||||
size="m"
|
||||
className="group hover:bg-state-destructive-hover!"
|
||||
onClick={handleItemRemove(index)}
|
||||
>
|
||||
<span className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user