mirror of
https://github.com/langgenius/dify.git
synced 2026-05-19 08:17:14 +08:00
Compare commits
24 Commits
copilot/ch
...
codex/dify
| Author | SHA1 | Date | |
|---|---|---|---|
| d8dd5fe0d9 | |||
| 521af725e4 | |||
| 8208ac0306 | |||
| e2fa7e229b | |||
| cd04f1d962 | |||
| 06f076e0ff | |||
| 5b79f7e99d | |||
| 1cee1a25b6 | |||
| c0f237bf35 | |||
| 75d7fc0526 | |||
| c057b5c5ff | |||
| 2a74ab1e9c | |||
| 4a27cd94c4 | |||
| 88597c1b93 | |||
| 261cbfff12 | |||
| 0d0f5d68df | |||
| 02245a17c3 | |||
| 933e327519 | |||
| 362624adcd | |||
| c1779112c6 | |||
| f404b3eac1 | |||
| 78394689c2 | |||
| 9abaa60cfc | |||
| afa212ff80 |
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_SHA=${BASE_SHA:-}
|
||||
HEAD_SHA=${HEAD_SHA:-}
|
||||
MAIN_REF=${MAIN_REF:-origin/main}
|
||||
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
|
||||
|
||||
error() {
|
||||
printf 'ERROR: %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
|
||||
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Base commit '$BASE_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
|
||||
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
failed=0
|
||||
checked=0
|
||||
|
||||
while IFS= read -r commit_sha; do
|
||||
[[ -n "$commit_sha" ]] || continue
|
||||
|
||||
checked=$((checked + 1))
|
||||
subject=$(git log -1 --format=%s "$commit_sha")
|
||||
source_sha=$(
|
||||
git log -1 --format=%B "$commit_sha" \
|
||||
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
|
||||
| tail -n 1
|
||||
)
|
||||
|
||||
if [[ -z "$source_sha" ]]; then
|
||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
|
||||
failed=1
|
||||
fi
|
||||
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
|
||||
|
||||
if [[ "$failed" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$checked" -eq 0 ]]; then
|
||||
echo "No PR commits to check."
|
||||
else
|
||||
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
|
||||
fi
|
||||
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
name: Hotfix Cherry-Pick Provenance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'hotfix/**'
|
||||
- 'lts/**'
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-cherry-pick-provenance:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch PR base, PR head, and main
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
|
||||
|
||||
- name: Load checker from main
|
||||
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
|
||||
- name: Check PR commits
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
MAIN_REF: origin/main
|
||||
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
|
||||
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
|
||||
"""Serialize a saved extension with the plaintext key used for response masking only.
|
||||
|
||||
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
|
||||
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
|
||||
"""
|
||||
return APIBasedExtensionResponse(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
api_endpoint=extension.api_endpoint,
|
||||
api_key=api_key,
|
||||
created_at=to_timestamp(extension.created_at),
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/code-based-extension")
|
||||
class CodeBasedExtensionAPI(Resource):
|
||||
@console_ns.doc("get_code_based_extension")
|
||||
@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource):
|
||||
api_key=payload.api_key,
|
||||
)
|
||||
|
||||
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
|
||||
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201
|
||||
|
||||
|
||||
@console_ns.route("/api-based-extension/<uuid:id>")
|
||||
@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource):
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)
|
||||
|
||||
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
|
||||
api_key_for_response = extension_data_from_db.api_key
|
||||
|
||||
extension_data_from_db.name = payload.name
|
||||
extension_data_from_db.api_endpoint = payload.api_endpoint
|
||||
|
||||
if payload.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = payload.api_key
|
||||
api_key_for_response = payload.api_key
|
||||
|
||||
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
|
||||
return _serialize_saved_api_based_extension(
|
||||
APIBasedExtensionService.save(extension_data_from_db),
|
||||
api_key_for_response,
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_api_based_extension")
|
||||
@console_ns.doc(description="Delete API-based extension")
|
||||
|
||||
@ -874,6 +874,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
|
||||
@console_ns.expect(console_ns.models[BuiltinProviderDefaultCredentialPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self, provider):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
"""Integration tests for console API-based extension endpoints using testcontainers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from libs.rsa import generate_key_pair
|
||||
from models import Tenant
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
)
|
||||
|
||||
|
||||
def _masked_api_key(api_key: str) -> str:
|
||||
if len(api_key) <= 8:
|
||||
return api_key[0] + "******" + api_key[-1]
|
||||
return api_key[:3] + "******" + api_key[-3:]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_extension_client(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> tuple[FlaskClient, dict[str, str], Tenant]:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
headers = authenticate_console_client(test_client_with_containers, account)
|
||||
return test_client_with_containers, headers, tenant
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_api_based_extension_ping():
|
||||
with patch("services.api_based_extension_service.APIBasedExtensionRequestor") as requestor:
|
||||
requestor.return_value.request.return_value = {"result": "pong"}
|
||||
yield requestor
|
||||
|
||||
|
||||
def test_create_response_masks_plaintext_api_key(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
api_key = "plain-secret-12345"
|
||||
|
||||
response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json is not None
|
||||
assert response.json["api_key"] == _masked_api_key(api_key)
|
||||
|
||||
|
||||
def test_update_response_masks_new_plaintext_api_key(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
new_api_key = "new-secret-67890"
|
||||
create_response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": "old-secret-12345",
|
||||
},
|
||||
)
|
||||
assert create_response.json is not None
|
||||
|
||||
update_response = client.post(
|
||||
f"/console/api/api-based-extension/{create_response.json['id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API Updated",
|
||||
"api_endpoint": "https://docs.example.com/v2",
|
||||
"api_key": new_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json is not None
|
||||
assert update_response.json["api_key"] == _masked_api_key(new_api_key)
|
||||
|
||||
|
||||
def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
existing_api_key = "old-secret-12345"
|
||||
create_response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": existing_api_key,
|
||||
},
|
||||
)
|
||||
assert create_response.json is not None
|
||||
|
||||
update_response = client.post(
|
||||
f"/console/api/api-based-extension/{create_response.json['id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API Updated",
|
||||
"api_endpoint": "https://docs.example.com/v2",
|
||||
"api_key": HIDDEN_VALUE,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json is not None
|
||||
assert update_response.json["api_key"] == _masked_api_key(existing_api_key)
|
||||
@ -44,6 +44,12 @@ def _make_extension(
|
||||
return extension
|
||||
|
||||
|
||||
def _masked_api_key(api_key: str) -> str:
|
||||
if len(api_key) <= 8:
|
||||
return api_key[0] + "******" + api_key[-1]
|
||||
return api_key[:3] + "******" + api_key[-3:]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""Bypass console decorators so handlers can run in isolation."""
|
||||
@ -114,7 +120,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat
|
||||
|
||||
|
||||
def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
saved_extension = _make_extension(name="Docs API", api_key="saved-secret")
|
||||
saved_extension = _make_extension(name="Docs API", api_key="encrypted-token-from-save")
|
||||
save_mock = MagicMock(return_value=saved_extension)
|
||||
monkeypatch.setattr("controllers.console.extension.APIBasedExtensionService.save", save_mock)
|
||||
|
||||
@ -125,7 +131,7 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
|
||||
}
|
||||
|
||||
with app.test_request_context("/console/api/api-based-extension", method="POST", json=payload):
|
||||
response = APIBasedExtensionAPI().post()
|
||||
response, status = APIBasedExtensionAPI().post()
|
||||
|
||||
args, _ = save_mock.call_args
|
||||
created_extension: APIBasedExtension = args[0]
|
||||
@ -133,7 +139,9 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
|
||||
assert created_extension.name == payload["name"]
|
||||
assert created_extension.api_endpoint == payload["api_endpoint"]
|
||||
assert created_extension.api_key == payload["api_key"]
|
||||
assert status == 201
|
||||
assert response["name"] == saved_extension.name
|
||||
assert response["api_key"] == _masked_api_key(payload["api_key"])
|
||||
save_mock.assert_called_once()
|
||||
|
||||
|
||||
@ -183,6 +191,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey
|
||||
assert existing_extension.api_key == "keep-me"
|
||||
save_mock.assert_called_once_with(existing_extension)
|
||||
assert response["name"] == payload["name"]
|
||||
assert response["api_key"] == _masked_api_key("keep-me")
|
||||
|
||||
|
||||
def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
@ -212,6 +221,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas
|
||||
assert existing_extension.api_key == "new-secret"
|
||||
save_mock.assert_called_once_with(existing_extension)
|
||||
assert response["name"] == payload["name"]
|
||||
assert response["api_key"] == _masked_api_key(payload["api_key"])
|
||||
|
||||
|
||||
def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@ -5,7 +5,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
### What's Updated
|
||||
|
||||
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
|
||||
For more information, refer `docker/certbot/README.md`.
|
||||
For more information, refer to `docker/certbot/README.md`.
|
||||
|
||||
- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments.
|
||||
|
||||
@ -17,26 +17,26 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
### How to Deploy Dify with `docker-compose.yaml`
|
||||
|
||||
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
|
||||
1. **Environment Setup**:
|
||||
2. **Environment Setup**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Copy `.env.example` to `.env`.
|
||||
- Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings.
|
||||
- **Optional (for advanced deployments)**:
|
||||
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
|
||||
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
|
||||
1. **Running the Services**:
|
||||
3. **Running the Services**:
|
||||
- Execute `docker compose up -d` from the `docker` directory to start the services.
|
||||
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`.
|
||||
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. See `envs/vectorstores/` for the full list of supported options.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
1. **SSL Certificate Setup**:
|
||||
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
1. **OpenTelemetry Collector Setup**:
|
||||
- Change `ENABLE_OTEL` to `true` in `.env`.
|
||||
- Configure `OTLP_BASE_ENDPOINT` properly.
|
||||
4. **SSL Certificate Setup**:
|
||||
- Refer to `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
5. **OpenTelemetry Collector Setup**:
|
||||
- Copy `envs/core-services/shared.env.example` to `envs/core-services/shared.env`.
|
||||
- Set `ENABLE_OTEL=true` and configure `OTLP_BASE_ENDPOINT`. Tune the other `OTEL_*` knobs in the same file if needed.
|
||||
|
||||
### How to Deploy Middleware for Developing Dify
|
||||
|
||||
@ -44,7 +44,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
- Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches.
|
||||
- Navigate to the `docker` directory.
|
||||
- Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file).
|
||||
1. **Running Middleware Services**:
|
||||
2. **Running Middleware Services**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance.
|
||||
|
||||
@ -55,9 +55,9 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
For users migrating from the `docker-legacy` setup:
|
||||
|
||||
1. **Review Changes**: Familiarize yourself with the new `.env` configuration and Docker Compose setup.
|
||||
1. **Transfer Customizations**:
|
||||
2. **Transfer Customizations**:
|
||||
- If you have customized configurations such as `docker-compose.yaml`, `ssrf_proxy/squid.conf`, or `nginx/conf.d/default.conf`, you will need to reflect these changes in the `.env` file you create.
|
||||
1. **Data Migration**:
|
||||
3. **Data Migration**:
|
||||
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
|
||||
|
||||
### Overview of `.env`, `.env.example`, and `envs/`
|
||||
@ -80,49 +80,51 @@ The root `.env.example` file contains the essential startup settings. Optional a
|
||||
|
||||
1. **Common Variables**:
|
||||
|
||||
- `CONSOLE_API_URL`, `SERVICE_API_URL`: URLs for different API services.
|
||||
- `APP_WEB_URL`: Frontend application URL.
|
||||
- `FILES_URL`: Base URL for file downloads and previews.
|
||||
- `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: URLs for the API and frontend services.
|
||||
- `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews.
|
||||
- `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs.
|
||||
|
||||
See `.env.example` for the full list.
|
||||
|
||||
1. **Server Configuration**:
|
||||
2. **Server Configuration**:
|
||||
|
||||
- `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings.
|
||||
- `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself.
|
||||
|
||||
1. **Database Configuration**:
|
||||
3. **Database Configuration**:
|
||||
|
||||
- `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`: PostgreSQL database credentials and connection details.
|
||||
|
||||
1. **Redis Configuration**:
|
||||
4. **Redis Configuration**:
|
||||
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis server connection settings.
|
||||
- `REDIS_KEY_PREFIX`: Optional global namespace prefix for Redis keys, topics, streams, and Celery Redis transport artifacts.
|
||||
|
||||
1. **Celery Configuration**:
|
||||
5. **Celery Configuration**:
|
||||
|
||||
- `CELERY_BROKER_URL`: Configuration for Celery message broker.
|
||||
|
||||
1. **Storage Configuration**:
|
||||
6. **Storage Configuration**:
|
||||
|
||||
- `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`.
|
||||
|
||||
1. **Vector Database Configuration**:
|
||||
7. **Vector Database Configuration**:
|
||||
|
||||
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`).
|
||||
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`). See `envs/vectorstores/` for the full list of supported options.
|
||||
- Specific settings for each vector store like `WEAVIATE_ENDPOINT`, `MILVUS_URI`.
|
||||
|
||||
1. **CORS Configuration**:
|
||||
8. **CORS Configuration**:
|
||||
|
||||
- `WEB_API_CORS_ALLOW_ORIGINS`, `CONSOLE_CORS_ALLOW_ORIGINS`: Settings for cross-origin resource sharing.
|
||||
|
||||
1. **OpenTelemetry Configuration**:
|
||||
9. **OpenTelemetry Configuration**:
|
||||
|
||||
- `ENABLE_OTEL`: Enable OpenTelemetry collector in api.
|
||||
- `OTLP_BASE_ENDPOINT`: Endpoint for your OTLP exporter.
|
||||
|
||||
1. **Other Service-Specific Environment Variables**:
|
||||
10. **Other Service-Specific Environment Variables**:
|
||||
|
||||
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
|
||||
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
|
||||
|
||||
### Environment Variables Synchronization
|
||||
|
||||
|
||||
@ -4642,11 +4642,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/client.spec.ts": {
|
||||
"next/no-assign-module-variable": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/common.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 29
|
||||
|
||||
@ -29,6 +29,8 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
@ -37,18 +39,48 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./checkbox`, `./checkbox-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
|
||||
Utilities:
|
||||
|
||||
- `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
|
||||
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root.
|
||||
|
||||
## Form contract
|
||||
|
||||
Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts.
|
||||
|
||||
Use `Form` for the submit boundary. It renders a native `<form>`, preserves Enter-to-submit and submit-button behavior, and adds Base UI's `onFormSubmit`, `errors`, `actionsRef`, and `validationMode` APIs for structured values and consolidated field validation. Prefer it over a bare `<form>` when the form is composed with Dify UI fields.
|
||||
|
||||
Use `FieldRoot` for each named field. A field must have a stable `name`, a visible `FieldLabel`, and either a `FieldControl` or another control that participates in the same Base UI field context. `FieldLabel`, `FieldDescription`, and `FieldError` provide the label and message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
|
||||
|
||||
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, or multi-thumb sliders. Compose group controls with the Base UI pattern:
|
||||
|
||||
```tsx
|
||||
<FieldRoot name="allowedNetworkProtocols">
|
||||
<FieldsetRoot render={<CheckboxGroup />}>
|
||||
<FieldsetLegend>Allowed network protocols</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="https" />
|
||||
HTTPS
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
```
|
||||
|
||||
`FieldsetRoot` provides the group semantics and legend relationship. It does not own the interactive state of the grouped control. Pass `disabled`, `value`, `defaultValue`, and change handlers to the actual group primitive (`CheckboxGroup`, radio group, slider root, etc.) instead of relying on the fieldset wrapper to manage them.
|
||||
|
||||
For complex business forms, keep state ownership outside these primitives. TanStack Form, zod, server validation, dialog reset behavior, and schema-driven rendering belong to the feature layer in `web/`; they should pass `name`, `invalid`, `dirty`, `touched`, `value`, `onValueChange`, and errors into these primitives rather than replacing the field semantics.
|
||||
|
||||
Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `<form>`, attach visible field names through `FieldLabel`, expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`.
|
||||
|
||||
## Tailwind CSS v4 integration
|
||||
|
||||
This package uses Tailwind CSS v4's CSS-first configuration model. Consumers should import Tailwind from their own root stylesheet, then import this package's CSS entry:
|
||||
@ -138,6 +170,9 @@ See `[AGENTS.md](./AGENTS.md)` for:
|
||||
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
|
||||
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.
|
||||
|
||||
[Base UI Field]: https://base-ui.com/react/components/field
|
||||
[Base UI Fieldset]: https://base-ui.com/react/components/fieldset
|
||||
[Base UI Form]: https://base-ui.com/react/components/form
|
||||
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
|
||||
[Base UI]: https://base-ui.com/react
|
||||
[Overlay & portal contract]: #overlay--portal-contract
|
||||
|
||||
@ -53,6 +53,18 @@
|
||||
"types": "./src/dropdown-menu/index.tsx",
|
||||
"import": "./src/dropdown-menu/index.tsx"
|
||||
},
|
||||
"./field": {
|
||||
"types": "./src/field/index.tsx",
|
||||
"import": "./src/field/index.tsx"
|
||||
},
|
||||
"./fieldset": {
|
||||
"types": "./src/fieldset/index.tsx",
|
||||
"import": "./src/fieldset/index.tsx"
|
||||
},
|
||||
"./form": {
|
||||
"types": "./src/form/index.tsx",
|
||||
"import": "./src/form/index.tsx"
|
||||
},
|
||||
"./meter": {
|
||||
"types": "./src/meter/index.tsx",
|
||||
"import": "./src/meter/index.tsx"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Field } from '@base-ui/react/field'
|
||||
import { Fieldset } from '@base-ui/react/fieldset'
|
||||
import { useState } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { Checkbox } from '../../checkbox'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { CheckboxGroup } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
@ -43,26 +43,26 @@ describe('CheckboxGroup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should compose with Base UI Field and Fieldset without losing labels', async () => {
|
||||
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<Field.Root name="features">
|
||||
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
|
||||
<Fieldset.Legend>Features</Fieldset.Legend>
|
||||
<Field.Item>
|
||||
<Field.Label>
|
||||
<FieldRoot name="features">
|
||||
<FieldsetRoot render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
|
||||
<FieldsetLegend>Features</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Checkbox value="search" />
|
||||
Search
|
||||
</Field.Label>
|
||||
</Field.Item>
|
||||
<Field.Item>
|
||||
<Field.Label>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Checkbox value="analytics" />
|
||||
Analytics
|
||||
</Field.Label>
|
||||
</Field.Item>
|
||||
</Fieldset.Root>
|
||||
</Field.Root>,
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
const analytics = screen.getByRole('checkbox', { name: 'Analytics' })
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Field } from '@base-ui/react/field'
|
||||
import { Fieldset } from '@base-ui/react/fieldset'
|
||||
import { useId, useState } from 'react'
|
||||
import { CheckboxGroup } from '.'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/CheckboxGroup',
|
||||
title: 'Base/Form/CheckboxGroup',
|
||||
component: CheckboxGroup,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
@ -75,11 +80,11 @@ function DynamicFormFieldDemo() {
|
||||
const [selected, setSelected] = useState<string[]>(['markdown'])
|
||||
|
||||
return (
|
||||
<Field.Root name="allowed_file_types" className="flex w-80 flex-col gap-2">
|
||||
<Field.Description className="body-xs-regular text-text-tertiary">
|
||||
<FieldRoot name="allowed_file_types" className="flex w-80 flex-col gap-2">
|
||||
<FieldDescription className="body-xs-regular text-text-tertiary">
|
||||
This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array.
|
||||
</Field.Description>
|
||||
<Fieldset.Root
|
||||
</FieldDescription>
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<CheckboxGroup
|
||||
value={selected}
|
||||
@ -88,19 +93,19 @@ function DynamicFormFieldDemo() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Fieldset.Legend className="system-sm-medium text-text-secondary">
|
||||
<FieldsetLegend className="system-sm-medium text-text-secondary">
|
||||
Allowed file types
|
||||
</Fieldset.Legend>
|
||||
</FieldsetLegend>
|
||||
{options.map(option => (
|
||||
<Field.Item key={option.value}>
|
||||
<Field.Label className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<FieldItem key={option.value}>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Checkbox value={option.value} />
|
||||
{option.label}
|
||||
</Field.Label>
|
||||
</Field.Item>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</Fieldset.Root>
|
||||
</Field.Root>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Checkbox',
|
||||
title: 'Base/Form/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from '.'
|
||||
import { Button } from '../button'
|
||||
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '../field'
|
||||
import { Form } from '../form'
|
||||
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
@ -139,6 +141,89 @@ export const Controlled: Story = {
|
||||
render: () => <ControlledDemo />,
|
||||
}
|
||||
|
||||
type ApiExtensionFormValues = {
|
||||
name: string
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const FormDialogDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen} disablePointerDismissal>
|
||||
<DialogTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Configure API extension
|
||||
</DialogTrigger>
|
||||
<DialogContent backdropProps={{ forceRender: true }} className="w-160">
|
||||
<DialogCloseButton />
|
||||
<div className="grid gap-2 pr-8">
|
||||
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
|
||||
Configure API extension
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-5 text-text-secondary">
|
||||
Save the endpoint and credentials used by this workspace integration.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<ApiExtensionFormValues>
|
||||
className="grid gap-4 pt-5"
|
||||
onFormSubmit={() => setOpen(false)}
|
||||
>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl required placeholder="Production API" />
|
||||
<FieldError match="valueMissing">Name is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl type="url" required placeholder="https://api.example.com" />
|
||||
<FieldDescription>
|
||||
<a
|
||||
href="https://docs.dify.ai/use-dify/workspace/api-extension/api-extension"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
>
|
||||
View API extension docs
|
||||
</a>
|
||||
</FieldDescription>
|
||||
<FieldError match="valueMissing">Endpoint is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot
|
||||
name="apiKey"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && value.length < 5)
|
||||
return 'API key must be at least 5 characters.'
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl required placeholder="sk-..." />
|
||||
<FieldError match="valueMissing">API key is required.</FieldError>
|
||||
<FieldError match="customError" />
|
||||
</FieldRoot>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormDialog: Story = {
|
||||
render: () => <FormDialogDemo />,
|
||||
}
|
||||
|
||||
export const ScrollingContent: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
|
||||
126
packages/dify-ui/src/field/__tests__/index.spec.tsx
Normal file
126
packages/dify-ui/src/field/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { Checkbox } from '../../checkbox'
|
||||
import { CheckboxGroup } from '../../checkbox-group'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { Form } from '../../form'
|
||||
import {
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Field primitives', () => {
|
||||
it('should associate label, description, and error with the control', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="profile form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="email">
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<FieldControl type="email" required />
|
||||
<FieldDescription>Used for account notifications.</FieldDescription>
|
||||
<FieldError match="valueMissing">Email is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Email' })
|
||||
const label = asHTMLElement(screen.getByText('Email').element())
|
||||
const description = asHTMLElement(screen.getByText('Used for account notifications.').element())
|
||||
|
||||
await expect.element(input).toHaveAccessibleDescription('Used for account notifications.')
|
||||
expect(label.tagName).toBe('LABEL')
|
||||
expect(label).toHaveAttribute('for', asHTMLElement(input.element()).id)
|
||||
expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toContain(description.id)
|
||||
await expect.element(input).toHaveClass('rounded-lg', 'system-sm-regular')
|
||||
await expect.element(screen.getByText('Email')).toHaveClass('py-1', 'system-sm-medium')
|
||||
await expect.element(screen.getByText('Used for account notifications.')).toHaveClass('py-0.5', 'body-xs-regular')
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const error = asHTMLElement(screen.getByText('Email is required.').element())
|
||||
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
|
||||
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
|
||||
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
|
||||
expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toEqual(
|
||||
expect.arrayContaining([description.id, error.id]),
|
||||
)
|
||||
})
|
||||
expect(onFormSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit valid field values through Base UI Form', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="settings form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="apiKey">
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl defaultValue="sk-test" required />
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ apiKey: 'sk-test' })
|
||||
})
|
||||
|
||||
it('should support external invalid state without requiring FieldControl', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="features" invalid>
|
||||
<FieldsetRoot render={<CheckboxGroup value={['search']} />}>
|
||||
<FieldsetLegend>Features</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="search" />
|
||||
Search
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldError match>Choose at least one feature.</FieldError>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'Features' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('checkbox', { name: 'Search' })).toHaveAttribute('aria-checked', 'true')
|
||||
await expect.element(screen.getByText('Choose at least one feature.')).toHaveClass('text-text-destructive', 'body-xs-regular')
|
||||
})
|
||||
|
||||
it('should apply design-system control sizes when requested', async () => {
|
||||
const screen = await render(
|
||||
<>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl size="large" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="alias">
|
||||
<FieldLabel>Alias</FieldLabel>
|
||||
<FieldControl size="small" />
|
||||
</FieldRoot>
|
||||
</>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Name' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Alias' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
|
||||
})
|
||||
|
||||
it('should expose the design-system read-only state', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="token">
|
||||
<FieldLabel>Token</FieldLabel>
|
||||
<FieldControl readOnly defaultValue="readonly-token" />
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('readonly')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveClass('read-only:cursor-default', 'read-only:focus:border-transparent')
|
||||
})
|
||||
})
|
||||
111
packages/dify-ui/src/field/index.stories.tsx
Normal file
111
packages/dify-ui/src/field/index.stories.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '../button'
|
||||
import {
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Field',
|
||||
component: FieldRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Field primitives built on Base UI Field. Use FieldRoot with FieldLabel, FieldControl, FieldDescription, and FieldError for one named form field. External form libraries can control invalid, dirty, and touched on FieldRoot.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FieldRoot>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const TextField: Story = {
|
||||
render: () => (
|
||||
<form className="grid w-96 gap-4">
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl type="url" required placeholder="https://api.example.com" />
|
||||
<FieldDescription>Used as the base URL for extension requests.</FieldDescription>
|
||||
<FieldError match="valueMissing">Endpoint is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
),
|
||||
}
|
||||
|
||||
export const MultipleFields: Story = {
|
||||
render: () => (
|
||||
<form className="grid w-96 gap-4">
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl required placeholder="Production API" />
|
||||
<FieldError match="valueMissing">Name is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl type="url" required placeholder="https://api.example.com" />
|
||||
<FieldDescription>Used as the base URL for extension requests.</FieldDescription>
|
||||
<FieldError match="valueMissing">Endpoint is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="apiKey">
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl required placeholder="sk-..." />
|
||||
<FieldDescription>Stored with the extension configuration.</FieldDescription>
|
||||
<FieldError match="valueMissing">API key is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
),
|
||||
}
|
||||
|
||||
export const ExternalInvalidState: Story = {
|
||||
render: () => (
|
||||
<FieldRoot name="apiKey" invalid className="w-96">
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl defaultValue="expired-key" />
|
||||
<FieldError match>API key has expired.</FieldError>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-96 gap-4">
|
||||
<FieldRoot name="smallEndpoint">
|
||||
<FieldLabel>Small</FieldLabel>
|
||||
<FieldControl size="small" placeholder="Small input" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularEndpoint">
|
||||
<FieldLabel>Regular</FieldLabel>
|
||||
<FieldControl placeholder="Regular input" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="largeEndpoint">
|
||||
<FieldLabel>Large</FieldLabel>
|
||||
<FieldControl size="large" placeholder="Large input" />
|
||||
</FieldRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
render: () => (
|
||||
<FieldRoot name="readonlyEndpoint" className="w-96">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl readOnly defaultValue="https://api.example.com" />
|
||||
<FieldDescription>This value is managed by the workspace owner.</FieldDescription>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
154
packages/dify-ui/src/field/index.tsx
Normal file
154
packages/dify-ui/src/field/index.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import type { Field as BaseFieldNS } from '@base-ui/react/field'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { Field as BaseField } from '@base-ui/react/field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type FieldRootProps
|
||||
= Omit<BaseFieldNS.Root.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type FieldRootActions = BaseFieldNS.Root.Actions
|
||||
|
||||
export function FieldRoot({
|
||||
className,
|
||||
...props
|
||||
}: FieldRootProps) {
|
||||
return (
|
||||
<BaseField.Root
|
||||
className={cn('group/field grid min-w-0 gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldItemProps
|
||||
= Omit<BaseFieldNS.Item.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldItem({
|
||||
className,
|
||||
...props
|
||||
}: FieldItemProps) {
|
||||
return (
|
||||
<BaseField.Item
|
||||
className={cn('grid min-w-0 gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldLabelProps
|
||||
= Omit<BaseFieldNS.Label.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: FieldLabelProps) {
|
||||
return (
|
||||
<BaseField.Label
|
||||
className={cn('w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldControlVariants = cva(
|
||||
[
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
|
||||
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
|
||||
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md px-2 py-[3px] system-xs-regular',
|
||||
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
|
||||
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
|
||||
|
||||
export type FieldControlProps
|
||||
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
|
||||
& VariantProps<typeof fieldControlVariants>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type FieldControlChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
|
||||
|
||||
export function FieldControl({
|
||||
className,
|
||||
size = 'medium',
|
||||
...props
|
||||
}: FieldControlProps) {
|
||||
return (
|
||||
<BaseField.Control
|
||||
className={cn(fieldControlVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldDescriptionProps
|
||||
= Omit<BaseFieldNS.Description.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldDescription({
|
||||
className,
|
||||
...props
|
||||
}: FieldDescriptionProps) {
|
||||
return (
|
||||
<BaseField.Description
|
||||
className={cn('py-0.5 text-text-tertiary body-xs-regular', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldErrorProps
|
||||
= Omit<BaseFieldNS.Error.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldError({
|
||||
className,
|
||||
...props
|
||||
}: FieldErrorProps) {
|
||||
return (
|
||||
<BaseField.Error
|
||||
className={cn('py-0.5 text-text-destructive body-xs-regular', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldValidityProps = BaseFieldNS.Validity.Props
|
||||
export type FieldValidityState = BaseFieldNS.Validity.State
|
||||
|
||||
export const FieldValidity = BaseField.Validity
|
||||
21
packages/dify-ui/src/fieldset/__tests__/index.spec.tsx
Normal file
21
packages/dify-ui/src/fieldset/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
FieldsetLegend,
|
||||
FieldsetRoot,
|
||||
} from '../index'
|
||||
|
||||
describe('Fieldset primitives', () => {
|
||||
it('should apply reset design-system classes', async () => {
|
||||
const screen = await render(
|
||||
<FieldsetRoot className="custom-root">
|
||||
<FieldsetLegend className="custom-legend">Permissions</FieldsetLegend>
|
||||
</FieldsetRoot>,
|
||||
)
|
||||
|
||||
const legend = screen.getByText('Permissions').element() as HTMLElement
|
||||
const fieldset = legend.closest('fieldset') as HTMLElement
|
||||
|
||||
await expect.element(fieldset).toHaveClass('m-0', 'min-w-0', 'border-0', 'p-0', 'custom-root')
|
||||
await expect.element(legend).toHaveClass('mb-1', 'py-1', 'system-sm-medium', 'text-text-secondary', 'custom-legend')
|
||||
})
|
||||
})
|
||||
56
packages/dify-ui/src/fieldset/index.stories.tsx
Normal file
56
packages/dify-ui/src/fieldset/index.stories.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { CheckboxGroup } from '../checkbox-group'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../field'
|
||||
import {
|
||||
FieldsetLegend,
|
||||
FieldsetRoot,
|
||||
} from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Fieldset',
|
||||
component: FieldsetRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Fieldset primitives built on Base UI Fieldset. Use FieldsetRoot and FieldsetLegend when one field is represented by a group of related controls such as checkbox groups, radio groups, or multi-thumb sliders. Fieldset provides group semantics and labeling; pass interactive state such as disabled and value to the actual group primitive.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FieldsetRoot>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const CheckboxGroupField: Story = {
|
||||
render: () => (
|
||||
<FieldRoot name="scopes" className="w-80">
|
||||
<FieldsetRoot render={<CheckboxGroup defaultValue={['read']} />}>
|
||||
<FieldsetLegend>Scopes</FieldsetLegend>
|
||||
<div className="grid gap-2">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="read" />
|
||||
Read
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="write" />
|
||||
Write
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="admin" />
|
||||
Admin
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
41
packages/dify-ui/src/fieldset/index.tsx
Normal file
41
packages/dify-ui/src/fieldset/index.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import type { Fieldset as BaseFieldsetNS } from '@base-ui/react/fieldset'
|
||||
import { Fieldset as BaseFieldset } from '@base-ui/react/fieldset'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type FieldsetRootProps
|
||||
= Omit<BaseFieldsetNS.Root.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldsetRoot({
|
||||
className,
|
||||
...props
|
||||
}: FieldsetRootProps) {
|
||||
return (
|
||||
<BaseFieldset.Root
|
||||
className={cn('m-0 min-w-0 border-0 p-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldsetLegendProps
|
||||
= Omit<BaseFieldsetNS.Legend.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldsetLegend({
|
||||
className,
|
||||
...props
|
||||
}: FieldsetLegendProps) {
|
||||
return (
|
||||
<BaseFieldset.Legend
|
||||
className={cn('mb-1 py-1 text-text-secondary system-sm-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
53
packages/dify-ui/src/form/__tests__/index.spec.tsx
Normal file
53
packages/dify-ui/src/form/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '../../field'
|
||||
import { Form } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Form primitive', () => {
|
||||
it('should render a native named form and merge custom class names', async () => {
|
||||
const screen = await render(
|
||||
<Form aria-label="profile form" className="custom-form">
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl defaultValue="Ada" />
|
||||
</FieldRoot>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('form', { name: 'profile form' })).toHaveClass('custom-form')
|
||||
})
|
||||
|
||||
it('should call onFormSubmit with submitted values', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="api form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl defaultValue="https://api.example.com" />
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
endpoint: 'https://api.example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose externally supplied errors through FieldError consumers', async () => {
|
||||
const screen = await render(
|
||||
<Form aria-label="server form" errors={{ token: 'Token has expired.' }}>
|
||||
<FieldRoot name="token">
|
||||
<FieldLabel>Token</FieldLabel>
|
||||
<FieldControl defaultValue="expired" />
|
||||
</FieldRoot>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
70
packages/dify-ui/src/form/index.stories.tsx
Normal file
70
packages/dify-ui/src/form/index.stories.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '../button'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { CheckboxGroup } from '../checkbox-group'
|
||||
import {
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
import { Form } from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Form',
|
||||
component: Form,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof Form>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Form className="grid w-96 gap-4" onFormSubmit={() => undefined}>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl required placeholder="Enter a name" />
|
||||
<FieldError match="valueMissing">Name is required.</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="email">
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<FieldControl type="email" required placeholder="name@example.com" />
|
||||
<FieldDescription>Used for account notifications.</FieldDescription>
|
||||
<FieldError match="valueMissing">Email is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="features">
|
||||
<FieldsetRoot render={<CheckboxGroup defaultValue={['search']} />}>
|
||||
<FieldsetLegend>Features</FieldsetLegend>
|
||||
<div className="grid gap-2">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="search" />
|
||||
Search
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="analytics" />
|
||||
Analytics
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
}
|
||||
11
packages/dify-ui/src/form/index.tsx
Normal file
11
packages/dify-ui/src/form/index.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { Form as BaseFormNS } from '@base-ui/react/form'
|
||||
import { Form as BaseForm } from '@base-ui/react/form'
|
||||
|
||||
export const Form = BaseForm
|
||||
|
||||
export type FormProps = BaseFormNS.Props
|
||||
export type FormActions = BaseFormNS.Actions
|
||||
export type FormValidationMode = BaseFormNS.ValidationMode
|
||||
export type FormSubmitEventDetails = BaseFormNS.SubmitEventDetails
|
||||
@ -108,7 +108,7 @@ const DemoField = ({
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/NumberField',
|
||||
title: 'Base/Form/NumberField',
|
||||
component: NumberField,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
const triggerWidth = 'w-64'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Select',
|
||||
title: 'Base/Form/Select',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { Slider } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Slider',
|
||||
title: 'Base/Form/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState, useTransition } from 'react'
|
||||
import { Switch, SwitchSkeleton } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Switch',
|
||||
title: 'Base/Form/Switch',
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -9,6 +9,9 @@ export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@base-ui/react/form'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
|
||||
@ -84,7 +84,7 @@ vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-genera
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({
|
||||
ApiBasedExtensionSelector: ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
|
||||
@ -13,7 +13,7 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import EmojiPicker from '@/app/components/base/emoji-picker'
|
||||
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
|
||||
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { useDocLink, useLocale } from '@/context/i18n'
|
||||
import { useCodeBasedExtensions } from '@/service/use-common'
|
||||
import {
|
||||
|
||||
@ -21,6 +21,7 @@ describe('checkbox list component', () => {
|
||||
)
|
||||
expect(screen.getByText('Test Title'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Test Description'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription('Test Description')
|
||||
options.forEach((option) => {
|
||||
expect(screen.getByText(option.label))!.toBeInTheDocument()
|
||||
})
|
||||
@ -231,6 +232,7 @@ describe('checkbox list component', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('group', { name: 'Test Label' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without showSelectAll, showCount, showSearch', () => {
|
||||
|
||||
@ -3,7 +3,9 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { FieldDescription, FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
@ -16,6 +18,7 @@ type CheckboxListOption = {
|
||||
}
|
||||
|
||||
type CheckboxListProps = {
|
||||
name?: string
|
||||
title?: string
|
||||
label?: string
|
||||
description?: string
|
||||
@ -31,6 +34,7 @@ type CheckboxListProps = {
|
||||
}
|
||||
|
||||
export const CheckboxList = ({
|
||||
name,
|
||||
title = '',
|
||||
label,
|
||||
description,
|
||||
@ -45,7 +49,6 @@ export const CheckboxList = ({
|
||||
maxHeight,
|
||||
}: CheckboxListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const groupLabelId = useId()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
@ -66,116 +69,129 @@ export const CheckboxList = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
|
||||
{label && (
|
||||
<div id={groupLabelId} className="system-sm-medium text-text-secondary">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CheckboxGroup
|
||||
aria-labelledby={label ? groupLabelId : undefined}
|
||||
value={value}
|
||||
onValueChange={nextValue => onChange?.(nextValue)}
|
||||
allValues={selectableOptionValues}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-components-panel-border bg-components-panel-bg"
|
||||
<FieldRoot name={name} className={cn('flex w-full flex-col gap-1', containerClassName)}>
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<CheckboxGroup
|
||||
aria-label={!label && title ? title : undefined}
|
||||
value={value}
|
||||
onValueChange={nextValue => onChange?.(nextValue)}
|
||||
allValues={selectableOptionValues}
|
||||
disabled={disabled}
|
||||
className="flex flex-col gap-1"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(showSelectAll || title || showSearch) && (
|
||||
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
|
||||
{!searchQuery && showSelectAll && (
|
||||
<label className={cn('flex shrink-0 items-center', !disabled && 'cursor-pointer')}>
|
||||
<Checkbox
|
||||
parent
|
||||
disabled={disabled}
|
||||
{label && (
|
||||
<FieldsetLegend className="mb-0">
|
||||
{label}
|
||||
</FieldsetLegend>
|
||||
)}
|
||||
{description && (
|
||||
<FieldDescription className="body-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</FieldDescription>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg">
|
||||
{(showSelectAll || title || showSearch) && (
|
||||
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
|
||||
{!searchQuery && showSelectAll && (
|
||||
<FieldItem disabled={disabled} className="shrink-0 gap-0">
|
||||
<FieldLabel className={cn('flex items-center p-0', !disabled && 'cursor-pointer')}>
|
||||
<Checkbox
|
||||
parent
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
)}
|
||||
{!searchQuery
|
||||
? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{title && (
|
||||
<span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{showCount && selectedCount > 0 && (
|
||||
<Badge uppercase>
|
||||
{t('operation.selectCount', { ns: 'common', count: selectedCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary">
|
||||
{
|
||||
filteredOptions.length > 0
|
||||
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
|
||||
: t('operation.noSearchCount', { ns: 'common', content: title })
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('placeholder.search', { ns: 'common' })}
|
||||
className="w-40"
|
||||
/>
|
||||
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
|
||||
</label>
|
||||
)}
|
||||
{!searchQuery
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="p-1"
|
||||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
|
||||
data-testid="options-container"
|
||||
>
|
||||
{!filteredOptions.length
|
||||
? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{title && (
|
||||
<span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{showCount && selectedCount > 0 && (
|
||||
<Badge uppercase>
|
||||
{t('operation.selectCount', { ns: 'common', count: selectedCount })}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="px-3 py-6 text-center text-sm text-text-tertiary">
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)
|
||||
: t('noData', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary">
|
||||
{
|
||||
filteredOptions.length > 0
|
||||
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
|
||||
: t('operation.noSearchCount', { ns: 'common', content: title })
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('placeholder.search', { ns: 'common' })}
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="p-1"
|
||||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
|
||||
data-testid="options-container"
|
||||
>
|
||||
{!filteredOptions.length
|
||||
? (
|
||||
<div className="px-3 py-6 text-center text-sm text-text-tertiary">
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)
|
||||
: t('noData', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
filteredOptions.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
data-testid="option-item"
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
|
||||
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
value={option.value}
|
||||
filteredOptions.map(option => (
|
||||
<FieldItem
|
||||
key={option.value}
|
||||
disabled={option.disabled || disabled}
|
||||
/>
|
||||
<span
|
||||
className="flex-1 truncate system-sm-medium text-text-secondary"
|
||||
title={option.label}
|
||||
className="gap-0"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
<FieldLabel
|
||||
data-testid="option-item"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
|
||||
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
value={option.value}
|
||||
disabled={option.disabled || disabled}
|
||||
/>
|
||||
<span
|
||||
className="flex-1 truncate system-sm-medium text-text-secondary"
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ vi.mock('../score-slider', () => ({
|
||||
<input
|
||||
role="slider"
|
||||
type="range"
|
||||
min={80}
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
|
||||
@ -272,7 +272,7 @@ describe('ConfigParamModal', () => {
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('min', '80')
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
expect(slider).toHaveValue('90')
|
||||
})
|
||||
@ -375,7 +375,7 @@ describe('ConfigParamModal', () => {
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
score_threshold: undefined as unknown as number,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
@ -390,6 +390,35 @@ describe('ConfigParamModal', () => {
|
||||
expect(screen.getByRole('slider')).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold instead of falling back to default', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveValue('0')
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
|
||||
@ -175,6 +175,22 @@ describe('AnnotationReply', () => {
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show zero score threshold when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when score threshold is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { queryAnnotationJobStatus } from '@/service/annotation'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import { sleep } from '@/utils'
|
||||
import useAnnotationConfig from '../use-annotation-config'
|
||||
|
||||
@ -162,6 +162,35 @@ describe('useAnnotationConfig', () => {
|
||||
expect(updatedConfig.score_threshold).toBe(0.85)
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when enabling annotation', async () => {
|
||||
const zeroScoreConfig = { ...defaultConfig, score_threshold: 0 }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: zeroScoreConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0)
|
||||
})
|
||||
|
||||
expect(updateAnnotationStatus).toHaveBeenCalledWith(
|
||||
'test-app',
|
||||
'enable',
|
||||
{
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
},
|
||||
0,
|
||||
)
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0]![0]
|
||||
expect(updatedConfig.score_threshold).toBe(0)
|
||||
})
|
||||
|
||||
it('should set score and embedding model together', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
|
||||
@ -75,7 +75,7 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a
|
||||
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
|
||||
<ScoreSlider
|
||||
className="mt-1"
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
|
||||
@ -100,7 +100,7 @@ const AnnotationReply = ({
|
||||
<div className="flex items-center gap-4 pt-0.5">
|
||||
<div className="">
|
||||
<div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold || '-'}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold ?? '-'}</div>
|
||||
</div>
|
||||
<div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div>
|
||||
<div className="">
|
||||
|
||||
@ -17,7 +17,7 @@ describe('ScoreSlider', () => {
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
|
||||
@ -36,4 +36,11 @@ describe('ScoreSlider', () => {
|
||||
expect(getSliderInput()).toHaveValue('95')
|
||||
expect(screen.getByText('0.95')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow zero as the minimum score threshold', () => {
|
||||
render(<ScoreSlider value={0} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toHaveValue('0')
|
||||
expect(screen.getByText('0.00')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,13 +17,16 @@ const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const SCORE_MIN = 0
|
||||
const SCORE_MAX = 100
|
||||
|
||||
const ScoreSlider: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const safeValue = clamp(value, 80, 100)
|
||||
const safeValue = clamp(value, SCORE_MIN, SCORE_MAX)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@ -31,8 +34,8 @@ const ScoreSlider: FC<Props> = ({
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={safeValue}
|
||||
min={80}
|
||||
max={100}
|
||||
min={SCORE_MIN}
|
||||
max={SCORE_MAX}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
|
||||
@ -40,7 +43,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
<div
|
||||
className="pointer-events-none absolute top-[-16px] system-sm-semibold text-text-primary"
|
||||
style={{
|
||||
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
|
||||
left: `calc(4px + ${safeValue / SCORE_MAX} * (100% - 8px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
@ -49,7 +52,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
</div>
|
||||
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
|
||||
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
|
||||
<div>0.8</div>
|
||||
<div>0.0</div>
|
||||
<div>·</div>
|
||||
<div>{t('feature.annotation.scoreThreshold.easyMatch', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
|
||||
@ -53,7 +53,7 @@ const useAnnotationConfig = ({
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.enabled = true
|
||||
draft.embedding_model = embeddingModel
|
||||
if (!draft.score_threshold)
|
||||
if (draft.score_threshold === undefined || draft.score_threshold === null)
|
||||
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
|
||||
}))
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ vi.mock('@/app/components/header/account-setting/constants', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
ApiBasedExtensionSelector: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
<div data-testid="api-selector">
|
||||
<button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDocLink, useLocale } from '@/context/i18n'
|
||||
|
||||
@ -305,6 +305,7 @@ const BaseField = ({
|
||||
{
|
||||
formItemType === FormTypeEnum.checkbox /* && multiple */ && (
|
||||
<CheckboxList
|
||||
name={field.name}
|
||||
title={name}
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
|
||||
@ -66,11 +66,33 @@ vi.mock('@/service/use-datasource', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
|
||||
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: new Proxy(actual.consoleQuery, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'apiBasedExtension') {
|
||||
return {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['console', 'api-based-extension'],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/billing/billing-page', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="billing-page" />,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Empty from '../empty'
|
||||
import { Empty } from '../empty'
|
||||
|
||||
describe('Empty State', () => {
|
||||
describe('Rendering', () => {
|
||||
|
||||
@ -1,33 +1,66 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionPage from '../index'
|
||||
import { ApiBasedExtensionPage } from '../index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
const {
|
||||
mockApiBasedExtensionsQuery,
|
||||
mockCreateApiBasedExtension,
|
||||
mockUpdateApiBasedExtension,
|
||||
mockDeleteApiBasedExtension,
|
||||
} = vi.hoisted(() => ({
|
||||
mockApiBasedExtensionsQuery: vi.fn(),
|
||||
mockCreateApiBasedExtension: vi.fn(),
|
||||
mockUpdateApiBasedExtension: vi.fn(),
|
||||
mockDeleteApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
updateApiBasedExtension: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
get: {
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(() => mockApiBasedExtensionsQuery()),
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('ApiBasedExtensionPage', () => {
|
||||
const mockRefetch = vi.fn<() => void>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render empty state when no data exists', () => {
|
||||
// Arrange
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -44,11 +77,10 @@ describe('ApiBasedExtensionPage', () => {
|
||||
{ id: '2', name: 'Extension 2', api_endpoint: 'url2', api_key: 'key2' },
|
||||
]
|
||||
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -63,11 +95,10 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
it('should handle loading state', () => {
|
||||
// Arrange
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
data: null,
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -112,11 +143,10 @@ describe('ApiBasedExtensionPage', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should open modal when clicking add button', () => {
|
||||
// Arrange
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -126,19 +156,18 @@ describe('ApiBasedExtensionPage', () => {
|
||||
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call refetch when add modal saves successfully', async () => {
|
||||
it('should close add modal when create mutation succeeds', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue({
|
||||
mockCreateApiBasedExtension.mockResolvedValue({
|
||||
id: 'new-id',
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -150,19 +179,25 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call refetch when an item is updated', async () => {
|
||||
it('should close edit modal when update mutation succeeds', async () => {
|
||||
// Arrange
|
||||
const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' }
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...extension, name: 'Updated' })
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockUpdateApiBasedExtension.mockResolvedValue({ ...extension, name: 'Updated' })
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [extension],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
render(<ApiBasedExtensionPage />)
|
||||
|
||||
@ -172,7 +207,17 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
body: {
|
||||
name: 'Extension 1',
|
||||
api_endpoint: 'url1',
|
||||
api_key: '[__HIDDEN__]',
|
||||
},
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.editTitle' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,11 +2,31 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-
|
||||
import type { TFunction } from 'i18next'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import Item from '../item'
|
||||
import { Item } from '../item'
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
deleteApiBasedExtension: vi.fn(),
|
||||
const { mockDeleteApiBasedExtension } = vi.hoisted(() => ({
|
||||
mockDeleteApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
byId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('Item Component', () => {
|
||||
@ -16,7 +36,6 @@ describe('Item Component', () => {
|
||||
api_endpoint: 'https://api.example.com',
|
||||
api_key: 'test-api-key',
|
||||
}
|
||||
const mockOnUpdate = vi.fn()
|
||||
const mockOnEdit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
@ -26,7 +45,7 @@ describe('Item Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render extension data correctly', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -44,7 +63,7 @@ describe('Item Component', () => {
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<Item data={minimalData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={minimalData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -56,7 +75,7 @@ describe('Item Component', () => {
|
||||
describe('Modal Interactions', () => {
|
||||
it('should request editing with the current extension when clicking edit button', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
@ -67,7 +86,7 @@ describe('Item Component', () => {
|
||||
describe('Deletion', () => {
|
||||
it('should show delete confirmation dialog when clicking delete button', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
// Assert
|
||||
@ -75,10 +94,10 @@ describe('Item Component', () => {
|
||||
expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
|
||||
it('should call delete mutation when confirming deletion', async () => {
|
||||
// Arrange
|
||||
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
mockDeleteApiBasedExtension.mockResolvedValue({})
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
@ -90,15 +109,18 @@ describe('Item Component', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(deleteApiBasedExtension).toHaveBeenCalledWith('/api-based-extension/1')
|
||||
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide delete confirmation dialog after successful deletion', async () => {
|
||||
// Arrange
|
||||
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
mockDeleteApiBasedExtension.mockResolvedValue({})
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
@ -116,7 +138,7 @@ describe('Item Component', () => {
|
||||
|
||||
it('should close delete confirmation when clicking cancel button', async () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
@ -128,13 +150,12 @@ describe('Item Component', () => {
|
||||
|
||||
it('should not call delete API when canceling deletion', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
// Assert
|
||||
expect(deleteApiBasedExtension).not.toHaveBeenCalled()
|
||||
expect(mockOnUpdate).not.toHaveBeenCalled()
|
||||
expect(mockDeleteApiBasedExtension).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -157,7 +178,7 @@ describe('Item Component', () => {
|
||||
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const editBtn = screen.getByText('operation.edit')
|
||||
const deleteBtn = allButtons.find(btn => btn !== editBtn)
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
import ApiBasedExtensionModal from '../modal'
|
||||
import { ApiBasedExtensionModal } from '../modal'
|
||||
|
||||
const { mockToast } = vi.hoisted(() => {
|
||||
const { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast } = vi.hoisted(() => {
|
||||
const mockCreateApiBasedExtension = vi.fn()
|
||||
const mockUpdateApiBasedExtension = vi.fn()
|
||||
const mockToast = Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@ -17,16 +18,35 @@ const { mockToast } = vi.hoisted(() => {
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
})
|
||||
return { mockToast }
|
||||
return { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast }
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
updateApiBasedExtension: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@ -35,7 +55,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
|
||||
describe('ApiBasedExtensionModal', () => {
|
||||
const mockOnOpenChange = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockOnSaved = vi.fn()
|
||||
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
|
||||
const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
|
||||
id: '1',
|
||||
@ -46,15 +66,34 @@ describe('ApiBasedExtensionModal', () => {
|
||||
})
|
||||
|
||||
const render = (ui: ReactElement) => RTLRender(ui)
|
||||
const renderModal = (props: Partial<ComponentProps<typeof ApiBasedExtensionModal>> = {}) => render(
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={{}}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
const renderModal = (props: {
|
||||
open?: boolean
|
||||
} | {
|
||||
mode: 'edit'
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
open?: boolean
|
||||
} = {}) => {
|
||||
if ('mode' in props) {
|
||||
return render(
|
||||
<ApiBasedExtensionModal
|
||||
open={props.open ?? true}
|
||||
mode="edit"
|
||||
apiBasedExtension={props.apiBasedExtension}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
<ApiBasedExtensionModal
|
||||
open={props.open ?? true}
|
||||
mode="create"
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
const expectCloseRequested = () => {
|
||||
const calls = mockOnOpenChange.mock.calls
|
||||
expect(calls[calls.length - 1]?.[0]).toBe(false)
|
||||
@ -73,9 +112,9 @@ describe('ApiBasedExtensionModal', () => {
|
||||
// Assert
|
||||
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' })).toHaveAttribute('required')
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiEndpoint.title' })).toHaveAccessibleDescription('common.apiBasedExtension.link')
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('required')
|
||||
})
|
||||
|
||||
it('should render correctly for editing an existing extension', () => {
|
||||
@ -83,7 +122,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
const data = mockExtension()
|
||||
|
||||
// Act
|
||||
renderModal({ extension: data })
|
||||
renderModal({ mode: 'edit', apiBasedExtension: data })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
|
||||
@ -102,7 +141,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
})
|
||||
|
||||
describe('Form Submissions', () => {
|
||||
it('should call addApiBasedExtension on save for new extension', async () => {
|
||||
it('should call create mutation on save for new extension', async () => {
|
||||
// Arrange
|
||||
const newExtension = mockExtension({
|
||||
id: 'new-id',
|
||||
@ -110,7 +149,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue(newExtension)
|
||||
mockCreateApiBasedExtension.mockResolvedValue(newExtension)
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
@ -121,23 +160,22 @@ describe('ApiBasedExtensionModal', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(addApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension',
|
||||
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
})
|
||||
expect(mockOnSave).toHaveBeenCalledWith(newExtension)
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateApiBasedExtension on save for existing extension', async () => {
|
||||
it('should call update mutation on save for existing extension', async () => {
|
||||
// Arrange
|
||||
const data = mockExtension({ api_key: 'long-secret-key' })
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
|
||||
renderModal({ extension: data })
|
||||
mockUpdateApiBasedExtension.mockResolvedValue({ ...data, name: 'Updated' })
|
||||
renderModal({ mode: 'edit', apiBasedExtension: data })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
|
||||
@ -145,8 +183,10 @@ describe('ApiBasedExtensionModal', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(updateApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension/1',
|
||||
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
body: {
|
||||
name: 'Updated',
|
||||
api_endpoint: 'url',
|
||||
@ -154,15 +194,15 @@ describe('ApiBasedExtensionModal', () => {
|
||||
},
|
||||
})
|
||||
expect(mockToast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
|
||||
expect(mockOnSave).toHaveBeenCalled()
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateApiBasedExtension with new api_key when key is changed', async () => {
|
||||
it('should call update mutation with new api_key when key is changed', async () => {
|
||||
// Arrange
|
||||
const data = mockExtension({ api_key: 'old-key' })
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
|
||||
renderModal({ extension: data })
|
||||
mockUpdateApiBasedExtension.mockResolvedValue({ ...data, api_key: 'new-longer-key' })
|
||||
renderModal({ mode: 'edit', apiBasedExtension: data })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
|
||||
@ -170,8 +210,10 @@ describe('ApiBasedExtensionModal', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(updateApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension/1',
|
||||
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
body: {
|
||||
name: 'Existing',
|
||||
api_endpoint: 'url',
|
||||
@ -194,29 +236,16 @@ describe('ApiBasedExtensionModal', () => {
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
expect(mockToast.error).toHaveBeenCalledWith('common.apiBasedExtension.modal.apiKey.lengthError')
|
||||
expect(addApiBasedExtension).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.apiKey.lengthError')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
expect(mockToast.error).not.toHaveBeenCalled()
|
||||
expect(mockCreateApiBasedExtension).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should work when onSave is not provided', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue(mockExtension({ id: 'new-id' }))
|
||||
renderModal({ onSave: undefined })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(addApiBasedExtension).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should request closing when clicking cancel button', () => {
|
||||
// Arrange
|
||||
renderModal()
|
||||
@ -294,7 +323,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||
|
||||
// Act
|
||||
const { container } = renderModal({ onSave: undefined })
|
||||
const { container } = renderModal()
|
||||
|
||||
// Assert
|
||||
const inputs = container.querySelectorAll('input')
|
||||
|
||||
@ -1,23 +1,50 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { addApiBasedExtension } from '@/service/common'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionSelector from '../selector'
|
||||
import { ApiBasedExtensionSelector } from '../selector'
|
||||
|
||||
const {
|
||||
mockApiBasedExtensionsQuery,
|
||||
mockCreateApiBasedExtension,
|
||||
mockUpdateApiBasedExtension,
|
||||
} = vi.hoisted(() => ({
|
||||
mockApiBasedExtensionsQuery: vi.fn(),
|
||||
mockCreateApiBasedExtension: vi.fn(),
|
||||
mockUpdateApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
get: {
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(() => mockApiBasedExtensionsQuery()),
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
@ -25,7 +52,6 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas
|
||||
describe('ApiBasedExtensionSelector', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
const mockData: ApiBasedExtensionResponse[] = [
|
||||
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test', api_key: 'key1' },
|
||||
@ -37,12 +63,11 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
} as unknown as ModalContextState)
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
refetch: mockRefetch,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
} as unknown as UseQueryResult<ApiBasedExtensionResponse[], Error>)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -105,9 +130,9 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should open add modal when clicking add button and refetches on save', async () => {
|
||||
it('should open add modal when clicking add button and close it after save', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue({
|
||||
mockCreateApiBasedExtension.mockResolvedValue({
|
||||
id: 'new-id',
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
@ -127,7 +152,14 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
const Empty = () => {
|
||||
export function Empty() {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
|
||||
@ -27,5 +27,3 @@ const Empty = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Empty
|
||||
|
||||
@ -1,36 +1,37 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import Empty from './empty'
|
||||
import Item from './item'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Empty } from './empty'
|
||||
import { Item } from './item'
|
||||
import { ApiBasedExtensionModal } from './modal'
|
||||
|
||||
type ApiBasedExtensionDialogState = {
|
||||
extension: Partial<ApiBasedExtensionResponse>
|
||||
onSave: () => void
|
||||
mode: 'create'
|
||||
} | {
|
||||
mode: 'edit'
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
} | null
|
||||
|
||||
const ApiBasedExtensionPage = () => {
|
||||
export function ApiBasedExtensionPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data, refetch: mutate, isPending: isLoading } = useApiBasedExtensions()
|
||||
const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
|
||||
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
setDialogState({
|
||||
extension: {},
|
||||
onSave: () => mutate(),
|
||||
mode: 'create',
|
||||
})
|
||||
}
|
||||
const handleEditApiBasedExtension = (extension: ApiBasedExtensionResponse) => {
|
||||
const handleEditApiBasedExtension = (apiBasedExtension: ApiBasedExtensionResponse) => {
|
||||
setDialogState({
|
||||
extension,
|
||||
onSave: () => mutate(),
|
||||
mode: 'edit',
|
||||
apiBasedExtension,
|
||||
})
|
||||
}
|
||||
const handleSaveApiBasedExtension = () => {
|
||||
dialogState?.onSave()
|
||||
const handleApiBasedExtensionSaved = () => {
|
||||
setDialogState(null)
|
||||
}
|
||||
const handleApiBasedExtensionModalOpenChange = (open: boolean) => {
|
||||
@ -41,18 +42,17 @@ const ApiBasedExtensionPage = () => {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
!isLoading && !data?.length && (
|
||||
!isLoading && !apiBasedExtensions.length && (
|
||||
<Empty />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!data?.length && (
|
||||
data.map(item => (
|
||||
!isLoading && !!apiBasedExtensions.length && (
|
||||
apiBasedExtensions.map(item => (
|
||||
<Item
|
||||
key={item.id}
|
||||
data={item}
|
||||
apiBasedExtension={item}
|
||||
onEdit={handleEditApiBasedExtension}
|
||||
onUpdate={() => mutate()}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@ -66,17 +66,26 @@ const ApiBasedExtensionPage = () => {
|
||||
{t('apiBasedExtension.add', { ns: 'common' })}
|
||||
</Button>
|
||||
{
|
||||
dialogState && (
|
||||
dialogState?.mode === 'create' && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={dialogState.extension}
|
||||
mode="create"
|
||||
onOpenChange={handleApiBasedExtensionModalOpenChange}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
onSaved={handleApiBasedExtensionSaved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
dialogState?.mode === 'edit' && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
mode="edit"
|
||||
apiBasedExtension={dialogState.apiBasedExtension}
|
||||
onOpenChange={handleApiBasedExtensionModalOpenChange}
|
||||
onSaved={handleApiBasedExtensionSaved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionPage
|
||||
|
||||
@ -8,38 +8,43 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type ItemProps = {
|
||||
data: ApiBasedExtensionResponse
|
||||
onEdit: (extension: ApiBasedExtensionResponse) => void
|
||||
onUpdate: () => void
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
onEdit: (apiBasedExtension: ApiBasedExtensionResponse) => void
|
||||
}
|
||||
const Item = ({
|
||||
data,
|
||||
export function Item({
|
||||
apiBasedExtension,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
}: ItemProps) => {
|
||||
}: ItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const deleteApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.delete.mutationOptions())
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
onEdit(data)
|
||||
onEdit(apiBasedExtension)
|
||||
}
|
||||
const handleDeleteApiBasedExtension = async () => {
|
||||
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
|
||||
|
||||
setShowDeleteConfirm(false)
|
||||
onUpdate()
|
||||
const handleDeleteApiBasedExtension = () => {
|
||||
deleteApiBasedExtensionMutation.mutate({
|
||||
params: {
|
||||
id: apiBasedExtension.id,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{data.name}</div>
|
||||
<div className="truncate text-xs text-text-tertiary">{data.api_endpoint}</div>
|
||||
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{apiBasedExtension.name}</div>
|
||||
<div className="truncate text-xs text-text-tertiary">{apiBasedExtension.api_endpoint}</div>
|
||||
</div>
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<Button
|
||||
@ -59,12 +64,15 @@ const Item = ({
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}
|
||||
{`${t('operation.delete', { ns: 'common' })} \u201C${apiBasedExtension.name}\u201D?`}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDeleteApiBasedExtension}>
|
||||
<AlertDialogConfirmButton
|
||||
disabled={deleteApiBasedExtensionMutation.isPending}
|
||||
onClick={handleDeleteApiBasedExtension}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' }) || ''}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
@ -73,5 +81,3 @@ const Item = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
||||
|
||||
@ -4,64 +4,67 @@ import type {
|
||||
} from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
|
||||
type ApiBasedExtensionField = 'name' | 'api_endpoint' | 'api_key'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type ApiBasedExtensionModalProps = {
|
||||
open: boolean
|
||||
extension: Partial<ApiBasedExtensionResponse>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave?: (newData: ApiBasedExtensionResponse) => void
|
||||
}
|
||||
const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBasedExtensionModalProps) => {
|
||||
onSaved: () => void
|
||||
} & ({
|
||||
mode: 'create'
|
||||
} | {
|
||||
mode: 'edit'
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
})
|
||||
|
||||
export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) {
|
||||
const { open, mode, onOpenChange, onSaved } = props
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [localData, setLocalData] = useState(extension)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const handleDataChange = (field: ApiBasedExtensionField, value: string) => {
|
||||
setLocalData({ ...localData, [field]: value })
|
||||
}
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
if (localData.api_key && localData.api_key.length < 5) {
|
||||
toast.error(t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' }))
|
||||
setLoading(false)
|
||||
const createApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.post.mutationOptions())
|
||||
const updateApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.post.mutationOptions())
|
||||
const editingApiBasedExtension = mode === 'edit' ? props.apiBasedExtension : null
|
||||
const isSaving = createApiBasedExtensionMutation.isPending || updateApiBasedExtensionMutation.isPending
|
||||
const nameLabel = t('apiBasedExtension.modal.name.title', { ns: 'common' })
|
||||
const apiEndpointLabel = t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })
|
||||
const apiKeyLabel = t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })
|
||||
|
||||
const handleSubmit = (formValues: ApiBasedExtensionPayload) => {
|
||||
const body: ApiBasedExtensionPayload = {
|
||||
name: formValues.name,
|
||||
api_endpoint: formValues.api_endpoint,
|
||||
api_key: formValues.api_key,
|
||||
}
|
||||
|
||||
if (editingApiBasedExtension) {
|
||||
updateApiBasedExtensionMutation.mutate({
|
||||
params: {
|
||||
id: editingApiBasedExtension.id,
|
||||
},
|
||||
body: {
|
||||
...body,
|
||||
api_key: editingApiBasedExtension.api_key === body.api_key ? '[__HIDDEN__]' : body.api_key,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
onSaved()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload: ApiBasedExtensionPayload = {
|
||||
name: localData.name || '',
|
||||
api_endpoint: localData.api_endpoint || '',
|
||||
api_key: localData.api_key || '',
|
||||
}
|
||||
let res = {} as ApiBasedExtensionResponse
|
||||
if (!extension.id) {
|
||||
res = await addApiBasedExtension({
|
||||
url: '/api-based-extension',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
else {
|
||||
res = await updateApiBasedExtension({
|
||||
url: `/api-based-extension/${extension.id}`,
|
||||
body: {
|
||||
...payload,
|
||||
api_key: extension.api_key === localData.api_key ? '[__HIDDEN__]' : payload.api_key,
|
||||
},
|
||||
})
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
}
|
||||
if (onSave)
|
||||
onSave(res)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
createApiBasedExtensionMutation.mutate({
|
||||
body,
|
||||
}, {
|
||||
onSuccess: onSaved,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -73,42 +76,71 @@ const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBa
|
||||
<DialogCloseButton />
|
||||
|
||||
<DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary">
|
||||
{extension.name
|
||||
{mode === 'edit'
|
||||
? t('apiBasedExtension.modal.editTitle', { ns: 'common' })
|
||||
: t('apiBasedExtension.modal.title', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<div className="py-2">
|
||||
<div className="text-sm leading-9 font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.name.title', { ns: 'common' })}
|
||||
<Form<ApiBasedExtensionPayload> className="grid gap-4 pt-2" onFormSubmit={handleSubmit}>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>{nameLabel}</FieldLabel>
|
||||
<FieldControl
|
||||
required
|
||||
defaultValue={editingApiBasedExtension?.name || ''}
|
||||
placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''}
|
||||
/>
|
||||
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: nameLabel })}</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="api_endpoint">
|
||||
<FieldLabel>{apiEndpointLabel}</FieldLabel>
|
||||
<FieldControl
|
||||
required
|
||||
defaultValue={editingApiBasedExtension?.api_endpoint || ''}
|
||||
placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''}
|
||||
/>
|
||||
<FieldDescription>
|
||||
<a
|
||||
href={docLink('/use-dify/workspace/api-extension/api-extension')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
>
|
||||
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</FieldDescription>
|
||||
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: apiEndpointLabel })}</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot
|
||||
name="api_key"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && value.length < 5)
|
||||
return t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' })
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel>{apiKeyLabel}</FieldLabel>
|
||||
<FieldControl
|
||||
required
|
||||
defaultValue={editingApiBasedExtension?.api_key || ''}
|
||||
placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''}
|
||||
/>
|
||||
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: apiKeyLabel })}</FieldError>
|
||||
<FieldError match="customError" />
|
||||
</FieldRoot>
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isSaving}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<input value={localData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
|
||||
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs font-normal text-text-accent">
|
||||
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</div>
|
||||
<input value={localData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="text-sm leading-9 font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })}
|
||||
</div>
|
||||
<input value={localData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!localData.name || !localData.api_endpoint || !localData.api_key || loading} onClick={handleSave}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default ApiBasedExtensionModal
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { ApiBasedExtensionModal } from './modal'
|
||||
|
||||
type ApiBasedExtensionSelectorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const ApiBasedExtensionSelector = ({
|
||||
export function ApiBasedExtensionSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: ApiBasedExtensionSelectorProps) => {
|
||||
}: ApiBasedExtensionSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const {
|
||||
setShowAccountSettingModal,
|
||||
} = useModalContext()
|
||||
const { data, refetch: mutate } = useApiBasedExtensions()
|
||||
const { data: apiBasedExtensions = [] } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
|
||||
const handleSelect = (id: string) => {
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const currentItem = data?.find(item => item.id === value)
|
||||
const currentItem = apiBasedExtensions.find(item => item.id === value)
|
||||
|
||||
const handleSaveApiBasedExtension = () => {
|
||||
mutate()
|
||||
const handleApiBasedExtensionSaved = () => {
|
||||
setAddModalOpen(false)
|
||||
}
|
||||
const handleAddModalOpenChange = (nextOpen: boolean) => {
|
||||
@ -96,12 +96,12 @@ const ApiBasedExtensionSelector = ({
|
||||
</div>
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{
|
||||
data?.map(item => (
|
||||
apiBasedExtensions.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className="w-full cursor-pointer rounded-md border-none bg-transparent px-3 py-1.5 text-left hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(item.id!)}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
>
|
||||
<div className="text-sm text-text-primary">{item.name}</div>
|
||||
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
|
||||
@ -131,14 +131,12 @@ const ApiBasedExtensionSelector = ({
|
||||
addModalOpen && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={{}}
|
||||
mode="create"
|
||||
onOpenChange={handleAddModalOpenChange}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
onSaved={handleApiBasedExtensionSaved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionSelector
|
||||
|
||||
@ -16,7 +16,7 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import ApiBasedExtensionPage from './api-based-extension-page'
|
||||
import { ApiBasedExtensionPage } from './api-based-extension-page'
|
||||
import DataSourcePage from './data-source-page-new'
|
||||
import LanguagePage from './language-page'
|
||||
import MembersPage from './members-page'
|
||||
|
||||
@ -13,6 +13,7 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
},
|
||||
]
|
||||
let isRulesLoading = false
|
||||
let isRulesPending = false
|
||||
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||
let currentModel: Record<string, unknown> | undefined = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
@ -49,7 +50,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: parameterRules,
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesLoading,
|
||||
isPending: isRulesPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -92,9 +93,21 @@ vi.mock('../../model-selector', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
|
||||
<button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (id: number) => void, supportedParameterNames?: string[] }) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return null
|
||||
|
||||
return <button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return {}
|
||||
|
||||
return { temperature: 0.8 }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../trigger', () => ({
|
||||
@ -126,6 +139,7 @@ describe('ModelParameterModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isRulesLoading = false
|
||||
isRulesPending = false
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'temperature',
|
||||
@ -194,7 +208,28 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
fireEvent.click(screen.getByText('Preset 1'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render preset control when visible parameters do not support preset keys', () => {
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'max_tokens',
|
||||
label: { en_US: 'Max Tokens' },
|
||||
type: 'int',
|
||||
default: 256,
|
||||
min: 1,
|
||||
max: 4096,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setModel when model selector picks another model', () => {
|
||||
@ -219,11 +254,29 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should render loading state when parameter rules are loading', () => {
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
isRulesPending = true
|
||||
parameterRules = []
|
||||
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
provider=""
|
||||
modelId=""
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open content when readonly is true', () => {
|
||||
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
@ -299,6 +352,7 @@ describe('ModelParameterModal', () => {
|
||||
it('should render the empty loading fallback when rules resolve to an empty list', () => {
|
||||
parameterRules = []
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import PresetsParameter from '../presets-parameter'
|
||||
import { getSupportedPresetConfig } from '../presets-parameter-utils'
|
||||
|
||||
describe('PresetsParameter', () => {
|
||||
beforeEach(() => {
|
||||
@ -47,4 +48,22 @@ describe('PresetsParameter', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should render presets when at least one preset parameter is supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['temperature']} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render presets when no preset parameters are supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['max_tokens']} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return only supported preset config keys', () => {
|
||||
expect(getSupportedPresetConfig(1, ['temperature'])).toEqual({
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,7 +24,7 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
@ -32,6 +32,7 @@ import {
|
||||
import ModelSelector from '../model-selector'
|
||||
import ParameterItem from './parameter-item'
|
||||
import PresetsParameter from './presets-parameter'
|
||||
import { getSupportedPresetConfig } from './presets-parameter-utils'
|
||||
import Trigger from './trigger'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
@ -75,10 +76,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isPending,
|
||||
isLoading,
|
||||
} = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = isPending || isLoading
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
@ -90,6 +90,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@ -125,13 +128,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -199,7 +199,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
<div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
export const getSupportedPresetConfig = (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (!tone?.config)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return { ...tone.config }
|
||||
|
||||
const supportedParameterNameSet = new Set(supportedParameterNames)
|
||||
|
||||
return Object.entries(tone.config).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNameSet.has(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@ -12,6 +12,8 @@ import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAn
|
||||
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
const PRESET_TONE_LIST = TONE_LIST.slice(0, 3)
|
||||
|
||||
const toneI18nKeyMap = {
|
||||
Creative: 'model.tone.Creative',
|
||||
Balanced: 'model.tone.Balanced',
|
||||
@ -27,10 +29,18 @@ const TONE_ICONS: Record<number, ReactNode> = {
|
||||
|
||||
type PresetsParameterProps = {
|
||||
onSelect: (toneId: number) => void
|
||||
supportedParameterNames?: string[]
|
||||
}
|
||||
|
||||
function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
function PresetsParameter({ onSelect, supportedParameterNames }: PresetsParameterProps) {
|
||||
const { t } = useTranslation()
|
||||
const supportedParameterNameSet = supportedParameterNames ? new Set(supportedParameterNames) : undefined
|
||||
const visiblePresetTones = supportedParameterNameSet
|
||||
? PRESET_TONE_LIST.filter(tone => Object.keys(tone.config ?? {}).some(key => supportedParameterNameSet.has(key)))
|
||||
: PRESET_TONE_LIST
|
||||
|
||||
if (!visiblePresetTones.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -47,7 +57,7 @@ function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
<span className="ml-0.5 i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{TONE_LIST.slice(0, 3).map(tone => (
|
||||
{visiblePresetTones.map(tone => (
|
||||
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
|
||||
{TONE_ICONS[tone.id]}
|
||||
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
|
||||
|
||||
@ -75,14 +75,58 @@ vi.mock('@/config', () => ({
|
||||
|
||||
// Mock PresetsParameter component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (toneId: number) => void, supportedParameterNames?: string[] }) => {
|
||||
const hasSupportedParameter = !supportedParameterNames || supportedParameterNames.some(name => ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name))
|
||||
if (!hasSupportedParameter)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const toneConfigMap: Record<number, Record<string, number> | undefined> = {
|
||||
1: {
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
presence_penalty: 0.1,
|
||||
frequency_penalty: 0.1,
|
||||
},
|
||||
2: {
|
||||
temperature: 0.5,
|
||||
top_p: 0.85,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: 0.3,
|
||||
},
|
||||
3: {
|
||||
temperature: 0.2,
|
||||
top_p: 0.75,
|
||||
presence_penalty: 0.5,
|
||||
frequency_penalty: 0.5,
|
||||
},
|
||||
}
|
||||
const toneConfig = toneConfigMap[toneId]
|
||||
if (!toneConfig)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return toneConfig
|
||||
|
||||
return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNames.includes(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ParameterItem component
|
||||
@ -148,10 +192,12 @@ const createDefaultProps = (overrides: Partial<{
|
||||
const setupModelParameterRulesMock = (config: {
|
||||
data?: ModelParameterRule[]
|
||||
isPending?: boolean
|
||||
isLoading?: boolean
|
||||
} = {}) => {
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: config.data ? { data: config.data } : undefined,
|
||||
isPending: config.isPending ?? false,
|
||||
isLoading: config.isLoading ?? config.isPending ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -188,6 +234,19 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render loading state when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ isPending: true, isLoading: false })
|
||||
const props = createDefaultProps({ provider: '', modelId: '' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters header', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@ -202,7 +261,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
@ -214,7 +273,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for azure_openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
|
||||
|
||||
// Act
|
||||
@ -224,6 +283,18 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter when no visible parameter supports presets', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'max_tokens', type: 'int' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter for non-preset providers', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@ -360,7 +431,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Creative preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -384,7 +463,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Balanced preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -407,7 +494,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Precise preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -430,7 +525,7 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply empty config for Custom preset (spreads undefined)', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -444,6 +539,27 @@ describe('LLMParamsPanel', () => {
|
||||
// Assert - Custom preset has no config, so only existing params are kept
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
|
||||
})
|
||||
|
||||
it('should apply only preset config keys supported by visible parameters', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-creative'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleParamChange', () => {
|
||||
|
||||
@ -10,7 +10,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ParameterItem from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item'
|
||||
import PresetsParameter from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { getSupportedPresetConfig } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
@ -29,20 +30,21 @@ const LLMParamsPanel = ({
|
||||
onCompletionParamsChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@ -65,7 +67,7 @@ const LLMParamsPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isRulesLoading) {
|
||||
return (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
@ -77,7 +79,10 @@ const LLMParamsPanel = ({
|
||||
<div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -305,6 +305,7 @@ const FormInputItem: FC<Props> = ({
|
||||
)}
|
||||
{isCheckbox && isConstant && (
|
||||
<CheckboxList
|
||||
name={variable}
|
||||
title={schema.label?.[language] || schema.label?.en_US || variable}
|
||||
value={checkboxListValue}
|
||||
onChange={handleCheckboxListChange}
|
||||
|
||||
28
web/service/annotation.spec.ts
Normal file
28
web/service/annotation.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { AnnotationEnableStatus } from '@/app/components/app/annotation/type'
|
||||
import { updateAnnotationStatus } from './annotation'
|
||||
import { post } from './base'
|
||||
|
||||
vi.mock('./base', () => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('annotation service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when updating annotation status', () => {
|
||||
updateAnnotationStatus('app-1', AnnotationEnableStatus.enable, {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
}, 0)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('apps/app-1/annotation-reply/enable', {
|
||||
body: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -7,7 +7,7 @@ export const fetchAnnotationConfig = (appId: string) => {
|
||||
}
|
||||
export const updateAnnotationStatus = (appId: string, action: AnnotationEnableStatus, embeddingModel?: EmbeddingModelConfig, score?: number) => {
|
||||
let body: any = {
|
||||
score_threshold: score || ANNOTATION_DEFAULT.score_threshold,
|
||||
score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold,
|
||||
}
|
||||
if (embeddingModel) {
|
||||
body = {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { MutationFunctionContext } from '@tanstack/react-query'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
@ -7,7 +8,6 @@ const loadGetBaseURL = async (isClientValue: boolean) => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/utils/client', () => ({ isClient: isClientValue, isServer: !isClientValue }))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// eslint-disable-next-line next/no-assign-module-variable
|
||||
const module = await import('./client')
|
||||
warnSpy.mockClear()
|
||||
return { getBaseURL: module.getBaseURL, warnSpy }
|
||||
@ -35,6 +35,14 @@ const createTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createApiBasedExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
|
||||
id: 'extension-1',
|
||||
name: 'Weather',
|
||||
api_endpoint: 'https://api.example.com/weather',
|
||||
api_key: 'secret-key',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Scenario: base URL selection and warnings.
|
||||
describe('getBaseURL', () => {
|
||||
beforeEach(() => {
|
||||
@ -258,3 +266,94 @@ describe('consoleQuery tag mutation defaults', () => {
|
||||
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: oRPC mutation defaults own shared API Extension cache behavior.
|
||||
describe('consoleQuery apiBasedExtension mutation defaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should add created API Extension to the list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
||||
const existingExtension = createApiBasedExtension({ id: 'extension-1', name: 'Existing' })
|
||||
const createdExtension = createApiBasedExtension({ id: 'extension-2', name: 'Created' })
|
||||
|
||||
queryClient.setQueryData(listKey, [existingExtension])
|
||||
|
||||
const mutationOptions = consoleQuery.apiBasedExtension.post.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
createdExtension,
|
||||
{
|
||||
body: {
|
||||
name: createdExtension.name,
|
||||
api_endpoint: createdExtension.api_endpoint,
|
||||
api_key: createdExtension.api_key,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([createdExtension, existingExtension])
|
||||
})
|
||||
|
||||
it('should update matching API Extension in the list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
||||
const targetExtension = createApiBasedExtension({ id: 'extension-1', name: 'Before' })
|
||||
const otherExtension = createApiBasedExtension({ id: 'extension-2', name: 'Other' })
|
||||
const updatedExtension = createApiBasedExtension({ ...targetExtension, name: 'After' })
|
||||
|
||||
queryClient.setQueryData(listKey, [targetExtension, otherExtension])
|
||||
|
||||
const mutationOptions = consoleQuery.apiBasedExtension.byId.post.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
updatedExtension,
|
||||
{
|
||||
params: {
|
||||
id: targetExtension.id,
|
||||
},
|
||||
body: {
|
||||
name: 'Ignored Client Name',
|
||||
api_endpoint: targetExtension.api_endpoint,
|
||||
api_key: '[__HIDDEN__]',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([updatedExtension, otherExtension])
|
||||
})
|
||||
|
||||
it('should remove deleted API Extension from the list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
||||
const deletedExtension = createApiBasedExtension({ id: 'extension-1', name: 'Delete me' })
|
||||
const remainingExtension = createApiBasedExtension({ id: 'extension-2', name: 'Keep me' })
|
||||
|
||||
queryClient.setQueryData(listKey, [deletedExtension, remainingExtension])
|
||||
|
||||
const mutationOptions = consoleQuery.apiBasedExtension.byId.delete.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
id: deletedExtension.id,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([remainingExtension])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
@ -89,6 +90,45 @@ export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleR
|
||||
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
|
||||
path: ['console'],
|
||||
experimental_defaults: {
|
||||
apiBasedExtension: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSuccess: (createdExtension, _variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.apiBasedExtension.get.queryKey(),
|
||||
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
|
||||
oldExtensions ? [createdExtension, ...oldExtensions] : oldExtensions,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSuccess: (updatedExtension, variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.apiBasedExtension.get.queryKey(),
|
||||
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
|
||||
oldExtensions?.map(extension => extension.id === variables.params.id
|
||||
? updatedExtension
|
||||
: extension),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_data, variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.apiBasedExtension.get.queryKey(),
|
||||
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
|
||||
oldExtensions?.filter(extension => extension.id !== variables.params.id),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
import type {
|
||||
ApiBasedExtensionListResponse,
|
||||
ApiBasedExtensionPayload,
|
||||
ApiBasedExtensionResponse,
|
||||
} from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type {
|
||||
DefaultModelResponse,
|
||||
Model,
|
||||
@ -275,26 +270,6 @@ export const fetchDataSourceNotionBinding = (url: string): Promise<{ result: str
|
||||
return get<{ result: string }>(url)
|
||||
}
|
||||
|
||||
export const fetchApiBasedExtensionList = (url: string): Promise<ApiBasedExtensionListResponse> => {
|
||||
return get<ApiBasedExtensionListResponse>(url)
|
||||
}
|
||||
|
||||
export const fetchApiBasedExtensionDetail = (url: string): Promise<ApiBasedExtensionResponse> => {
|
||||
return get<ApiBasedExtensionResponse>(url)
|
||||
}
|
||||
|
||||
export const addApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise<ApiBasedExtensionResponse> => {
|
||||
return post<ApiBasedExtensionResponse>(url, { body })
|
||||
}
|
||||
|
||||
export const updateApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise<ApiBasedExtensionResponse> => {
|
||||
return post<ApiBasedExtensionResponse>(url, { body })
|
||||
}
|
||||
|
||||
export const deleteApiBasedExtension = (url: string): Promise<{ result: string }> => {
|
||||
return del<{ result: string }>(url)
|
||||
}
|
||||
|
||||
export const fetchCodeBasedExtensionList = (url: string): Promise<CodeBasedExtension> => {
|
||||
return get<CodeBasedExtension>(url)
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { get, post } from './base'
|
||||
import { consoleClient } from './client'
|
||||
|
||||
/**
|
||||
* True iff `err` is a 401 Response thrown by `service/base.ts`.
|
||||
@ -52,7 +51,6 @@ export const commonQueryKeys = {
|
||||
accountIntegrates: [NAME_SPACE, 'account-integrates'] as const,
|
||||
pluginProviders: [NAME_SPACE, 'plugin-providers'] as const,
|
||||
notionConnection: [NAME_SPACE, 'notion-connection'] as const,
|
||||
apiBasedExtensions: [NAME_SPACE, 'api-based-extensions'] as const,
|
||||
codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const,
|
||||
invitationCheck: (params?: { workspace_id?: string, email?: string, token?: string }) => [
|
||||
NAME_SPACE,
|
||||
@ -319,13 +317,6 @@ export const useCodeBasedExtensions = (module: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useApiBasedExtensions = () => {
|
||||
return useQuery({
|
||||
queryKey: commonQueryKeys.apiBasedExtensions,
|
||||
queryFn: () => consoleClient.apiBasedExtension.get(),
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvitationCheck = (params?: { workspace_id?: string, email?: string, token?: string }, enabled?: boolean) => {
|
||||
return useQuery({
|
||||
queryKey: commonQueryKeys.invitationCheck(params),
|
||||
|
||||
Reference in New Issue
Block a user