Compare commits

...

10 Commits

Author SHA1 Message Date
autofix-ci[bot]
cce3cb5587 [autofix.ci] apply automated fixes 2026-02-14 12:19:19 +08:00
L1nSn0w
77aad22e61 refactor(api): replace AutoRenewRedisLock with DbMigrationAutoRenewLock
- Updated the database migration locking mechanism to use DbMigrationAutoRenewLock for improved clarity and functionality.
- Removed the AutoRenewRedisLock implementation and its associated tests.
- Adjusted integration and unit tests to reflect the new locking class and its usage in the upgrade_db command.
2026-02-14 12:19:19 +08:00
L1nSn0w
c9645186de refactor(api): replace heartbeat mechanism with AutoRenewRedisLock for database migration
- Removed the manual heartbeat function for renewing the Redis lock during database migrations.
- Integrated AutoRenewRedisLock to handle lock renewal automatically, simplifying the upgrade_db command.
- Updated unit tests to reflect changes in lock handling and error management during migrations.
2026-02-14 12:19:19 +08:00
L1nSn0w
46bbd1dc4b refactor(tests): replace hardcoded wait time with constant for clarity
- Introduced HEARTBEAT_WAIT_TIMEOUT_SECONDS constant to improve readability and maintainability of test code.
- Updated test assertions to use the new constant instead of a hardcoded value.
2026-02-14 12:19:19 +08:00
autofix-ci[bot]
44defff163 [autofix.ci] apply automated fixes 2026-02-14 12:19:19 +08:00
L1nSn0w
ff37f08e60 fix(api): improve logging for database migration lock release
- Added a migration_succeeded flag to track the success of database migrations.
- Enhanced logging messages to indicate the status of the migration when releasing the lock, providing clearer context for potential issues.
2026-02-14 12:19:19 +08:00
L1nSn0w
f5f8ab3f1f feat(api): implement heartbeat mechanism for database migration lock
- Added a heartbeat function to renew the Redis lock during database migrations, preventing long blockages from crashed processes.
- Updated the upgrade_db command to utilize the new locking mechanism with a configurable TTL.
- Removed the deprecated MIGRATION_LOCK_TTL from DeploymentConfig and related files.
- Enhanced unit tests to cover the new lock renewal behavior and error handling during migrations.
2026-02-14 12:19:19 +08:00
L1nSn0w
327eb947f0 feat(api): enhance database migration locking mechanism and configuration
- Introduced a configurable Redis lock TTL for database migrations in DeploymentConfig.
- Updated the upgrade_db command to handle lock release errors gracefully.
- Added documentation for the new MIGRATION_LOCK_TTL environment variable in the .env.example file and docker-compose.yaml.
2026-02-14 12:19:19 +08:00
dependabot[bot]
c7bbe05088 chore(deps): bump sqlparse from 0.5.3 to 0.5.4 in /api (#32315)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 12:05:46 +09:00
Coding On Star
210710e76d refactor(web): extract custom hooks from complex components and add comprehensive tests (#32301)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-13 17:21:34 +08:00
27 changed files with 3116 additions and 988 deletions

View File

@@ -30,6 +30,7 @@ from extensions.ext_redis import redis_client
from extensions.ext_storage import storage
from extensions.storage.opendal_storage import OpenDALStorage
from extensions.storage.storage_type import StorageType
from libs.db_migration_lock import DbMigrationAutoRenewLock
from libs.helper import email as email_validate
from libs.password import hash_password, password_pattern, valid_password
from libs.rsa import generate_key_pair
@@ -54,6 +55,8 @@ from tasks.remove_app_and_related_data_task import delete_draft_variables_batch
logger = logging.getLogger(__name__)
DB_UPGRADE_LOCK_TTL_SECONDS = 60
@click.command("reset-password", help="Reset the account password.")
@click.option("--email", prompt=True, help="Account email to reset password for")
@@ -727,8 +730,15 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
@click.command("upgrade-db", help="Upgrade the database")
def upgrade_db():
click.echo("Preparing database migration...")
lock = redis_client.lock(name="db_upgrade_lock", timeout=60)
lock = DbMigrationAutoRenewLock(
redis_client=redis_client,
name="db_upgrade_lock",
ttl_seconds=DB_UPGRADE_LOCK_TTL_SECONDS,
logger=logger,
log_context="db_migration",
)
if lock.acquire(blocking=False):
migration_succeeded = False
try:
click.echo(click.style("Starting database migration.", fg="green"))
@@ -737,6 +747,7 @@ def upgrade_db():
flask_migrate.upgrade()
migration_succeeded = True
click.echo(click.style("Database migration successful!", fg="green"))
except Exception as e:
@@ -744,7 +755,8 @@ def upgrade_db():
click.echo(click.style(f"Database migration failed: {e}", fg="red"))
raise SystemExit(1)
finally:
lock.release()
status = "successful" if migration_succeeded else "failed"
lock.release_safely(status=status)
else:
click.echo("Database migration skipped")

View File

@@ -0,0 +1,195 @@
"""
DB migration Redis lock with heartbeat renewal.
This is intentionally migration-specific. Background renewal is a trade-off that makes sense
for unbounded, blocking operations like DB migrations (DDL/DML) where the main thread cannot
periodically refresh the lock TTL.
Do NOT use this as a general-purpose lock primitive for normal application code. Prefer explicit
lock lifecycle management (e.g. redis-py Lock context manager + `extend()` / `reacquire()` from
the same thread) when execution flow is under control.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from redis.exceptions import LockNotOwnedError, RedisError
logger = logging.getLogger(__name__)
class DbMigrationAutoRenewLock:
"""
Redis lock wrapper that automatically renews TTL while held (migration-only).
Notes:
- We force `thread_local=False` when creating the underlying redis-py lock, because the
lock token must be accessible from the heartbeat thread for `reacquire()` to work.
- `release_safely()` is best-effort: it never raises, so it won't mask the caller's
primary error/exit code.
"""
_redis_client: Any
_name: str
_ttl_seconds: float
_renew_interval_seconds: float
_log_context: str | None
_logger: logging.Logger
_lock: Any
_stop_event: threading.Event | None
_thread: threading.Thread | None
_acquired: bool
def __init__(
self,
redis_client: Any,
name: str,
ttl_seconds: float = 60,
renew_interval_seconds: float | None = None,
*,
logger: logging.Logger | None = None,
log_context: str | None = None,
) -> None:
self._redis_client = redis_client
self._name = name
self._ttl_seconds = float(ttl_seconds)
self._renew_interval_seconds = (
float(renew_interval_seconds) if renew_interval_seconds is not None else max(0.1, self._ttl_seconds / 3)
)
self._logger = logger or logging.getLogger(__name__)
self._log_context = log_context
self._lock = None
self._stop_event = None
self._thread = None
self._acquired = False
@property
def name(self) -> str:
return self._name
def acquire(self, *args: Any, **kwargs: Any) -> bool:
"""
Acquire the lock and start heartbeat renewal on success.
Accepts the same args/kwargs as redis-py `Lock.acquire()`.
"""
self._lock = self._redis_client.lock(
name=self._name,
timeout=self._ttl_seconds,
thread_local=False,
)
acquired = bool(self._lock.acquire(*args, **kwargs))
self._acquired = acquired
if acquired:
self._start_heartbeat()
return acquired
def owned(self) -> bool:
if self._lock is None:
return False
try:
return bool(self._lock.owned())
except Exception:
# Ownership checks are best-effort and must not break callers.
return False
def _start_heartbeat(self) -> None:
if self._lock is None:
return
if self._stop_event is not None:
return
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._heartbeat_loop,
args=(self._lock, self._stop_event),
daemon=True,
name=f"DbMigrationAutoRenewLock({self._name})",
)
self._thread.start()
def _heartbeat_loop(self, lock: Any, stop_event: threading.Event) -> None:
while not stop_event.wait(self._renew_interval_seconds):
try:
lock.reacquire()
except LockNotOwnedError:
self._logger.warning(
"DB migration lock is no longer owned during heartbeat%s; stop renewing.",
f" ({self._log_context})" if self._log_context else "",
exc_info=True,
)
return
except RedisError:
self._logger.warning(
"Failed to renew DB migration lock due to Redis error%s; will retry.",
f" ({self._log_context})" if self._log_context else "",
exc_info=True,
)
except Exception:
self._logger.warning(
"Unexpected error while renewing DB migration lock%s; will retry.",
f" ({self._log_context})" if self._log_context else "",
exc_info=True,
)
def release_safely(self, *, status: str | None = None) -> None:
"""
Stop heartbeat and release lock. Never raises.
Args:
status: Optional caller-provided status (e.g. 'successful'/'failed') to add context to logs.
"""
lock = self._lock
if lock is None:
return
self._stop_heartbeat()
# Lock release errors should never mask the real error/exit code.
try:
lock.release()
except LockNotOwnedError:
self._logger.warning(
"DB migration lock not owned on release%s%s; ignoring.",
f" after {status} operation" if status else "",
f" ({self._log_context})" if self._log_context else "",
exc_info=True,
)
except RedisError:
self._logger.warning(
"Failed to release DB migration lock due to Redis error%s%s; ignoring.",
f" after {status} operation" if status else "",
f" ({self._log_context})" if self._log_context else "",
exc_info=True,
)
except Exception:
self._logger.warning(
"Unexpected error while releasing DB migration lock%s%s; ignoring.",
f" after {status} operation" if status else "",
f" ({self._log_context})" if self._log_context else "",
exc_info=True,
)
finally:
self._acquired = False
def _stop_heartbeat(self) -> None:
if self._stop_event is None:
return
self._stop_event.set()
if self._thread is not None:
# Best-effort join: if Redis calls are blocked, the daemon thread may remain alive.
join_timeout_seconds = max(0.5, min(5.0, self._renew_interval_seconds * 2))
self._thread.join(timeout=join_timeout_seconds)
if self._thread.is_alive():
self._logger.warning(
"DB migration lock heartbeat thread did not stop within %.2fs%s; ignoring.",
join_timeout_seconds,
f" ({self._log_context})" if self._log_context else "",
)
self._stop_event = None
self._thread = None

View File

@@ -0,0 +1,38 @@
"""
Integration tests for DbMigrationAutoRenewLock using real Redis via TestContainers.
"""
import time
import uuid
import pytest
from extensions.ext_redis import redis_client
from libs.db_migration_lock import DbMigrationAutoRenewLock
@pytest.mark.usefixtures("flask_app_with_containers")
def test_db_migration_lock_renews_ttl_and_releases():
lock_name = f"test:db_migration_auto_renew_lock:{uuid.uuid4().hex}"
# Keep base TTL very small, and renew frequently so the test is stable even on slower CI.
lock = DbMigrationAutoRenewLock(
redis_client=redis_client,
name=lock_name,
ttl_seconds=1.0,
renew_interval_seconds=0.2,
log_context="test_db_migration_lock",
)
acquired = lock.acquire(blocking=True, blocking_timeout=5)
assert acquired is True
# Wait beyond the base TTL; key should still exist due to renewal.
time.sleep(1.5)
ttl = redis_client.ttl(lock_name)
assert ttl > 0
lock.release_safely(status="successful")
# After release, the key should not exist.
assert redis_client.exists(lock_name) == 0

View File

@@ -0,0 +1,146 @@
import sys
import threading
import types
from unittest.mock import MagicMock
import commands
from libs.db_migration_lock import LockNotOwnedError, RedisError
HEARTBEAT_WAIT_TIMEOUT_SECONDS = 5.0
def _install_fake_flask_migrate(monkeypatch, upgrade_impl) -> None:
module = types.ModuleType("flask_migrate")
module.upgrade = upgrade_impl
monkeypatch.setitem(sys.modules, "flask_migrate", module)
def _invoke_upgrade_db() -> int:
try:
commands.upgrade_db.callback()
except SystemExit as e:
return int(e.code or 0)
return 0
def test_upgrade_db_skips_when_lock_not_acquired(monkeypatch, capsys):
monkeypatch.setattr(commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 1234)
lock = MagicMock()
lock.acquire.return_value = False
commands.redis_client.lock.return_value = lock
exit_code = _invoke_upgrade_db()
captured = capsys.readouterr()
assert exit_code == 0
assert "Database migration skipped" in captured.out
commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=1234, thread_local=False)
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_not_called()
def test_upgrade_db_failure_not_masked_by_lock_release(monkeypatch, capsys):
monkeypatch.setattr(commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 321)
lock = MagicMock()
lock.acquire.return_value = True
lock.release.side_effect = LockNotOwnedError("simulated")
commands.redis_client.lock.return_value = lock
def _upgrade():
raise RuntimeError("boom")
_install_fake_flask_migrate(monkeypatch, _upgrade)
exit_code = _invoke_upgrade_db()
captured = capsys.readouterr()
assert exit_code == 1
assert "Database migration failed: boom" in captured.out
commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=321, thread_local=False)
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
def test_upgrade_db_success_ignores_lock_not_owned_on_release(monkeypatch, capsys):
monkeypatch.setattr(commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 999)
lock = MagicMock()
lock.acquire.return_value = True
lock.release.side_effect = LockNotOwnedError("simulated")
commands.redis_client.lock.return_value = lock
_install_fake_flask_migrate(monkeypatch, lambda: None)
exit_code = _invoke_upgrade_db()
captured = capsys.readouterr()
assert exit_code == 0
assert "Database migration successful!" in captured.out
commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=999, thread_local=False)
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
def test_upgrade_db_renews_lock_during_migration(monkeypatch, capsys):
"""
Ensure the lock is renewed while migrations are running, so the base TTL can stay short.
"""
# Use a small TTL so the heartbeat interval triggers quickly.
monkeypatch.setattr(commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
lock = MagicMock()
lock.acquire.return_value = True
commands.redis_client.lock.return_value = lock
renewed = threading.Event()
def _reacquire():
renewed.set()
return True
lock.reacquire.side_effect = _reacquire
def _upgrade():
assert renewed.wait(HEARTBEAT_WAIT_TIMEOUT_SECONDS)
_install_fake_flask_migrate(monkeypatch, _upgrade)
exit_code = _invoke_upgrade_db()
_ = capsys.readouterr()
assert exit_code == 0
assert lock.reacquire.call_count >= 1
def test_upgrade_db_ignores_reacquire_errors(monkeypatch, capsys):
# Use a small TTL so heartbeat runs during the upgrade call.
monkeypatch.setattr(commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
lock = MagicMock()
lock.acquire.return_value = True
commands.redis_client.lock.return_value = lock
attempted = threading.Event()
def _reacquire():
attempted.set()
raise RedisError("simulated")
lock.reacquire.side_effect = _reacquire
def _upgrade():
assert attempted.wait(HEARTBEAT_WAIT_TIMEOUT_SECONDS)
_install_fake_flask_migrate(monkeypatch, _upgrade)
exit_code = _invoke_upgrade_db()
_ = capsys.readouterr()
assert exit_code == 0
assert lock.reacquire.call_count >= 1

View File

@@ -0,0 +1,125 @@
"""Unit tests for enterprise service integrations.
This module covers the enterprise-only default workspace auto-join behavior:
- Enterprise mode disabled: no external calls
- Successful join / skipped join: no errors
- Failures (network/invalid response/invalid UUID): soft-fail wrapper must not raise
"""
from unittest.mock import patch
import pytest
from services.enterprise.enterprise_service import (
DefaultWorkspaceJoinResult,
EnterpriseService,
try_join_default_workspace,
)
class TestJoinDefaultWorkspace:
def test_join_default_workspace_success(self):
account_id = "11111111-1111-1111-1111-111111111111"
response = {"workspace_id": "22222222-2222-2222-2222-222222222222", "joined": True, "message": "ok"}
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
mock_send_request.return_value = response
result = EnterpriseService.join_default_workspace(account_id=account_id)
assert isinstance(result, DefaultWorkspaceJoinResult)
assert result.workspace_id == response["workspace_id"]
assert result.joined is True
assert result.message == "ok"
mock_send_request.assert_called_once_with(
"POST",
"/default-workspace/members",
json={"account_id": account_id},
)
def test_join_default_workspace_invalid_response_format_raises(self):
account_id = "11111111-1111-1111-1111-111111111111"
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
mock_send_request.return_value = "not-a-dict"
with pytest.raises(ValueError, match="Invalid response format"):
EnterpriseService.join_default_workspace(account_id=account_id)
def test_join_default_workspace_invalid_account_id_raises(self):
with pytest.raises(ValueError):
EnterpriseService.join_default_workspace(account_id="not-a-uuid")
class TestTryJoinDefaultWorkspace:
def test_try_join_default_workspace_enterprise_disabled_noop(self):
with (
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
):
mock_config.ENTERPRISE_ENABLED = False
try_join_default_workspace("11111111-1111-1111-1111-111111111111")
mock_join.assert_not_called()
def test_try_join_default_workspace_successful_join_does_not_raise(self):
account_id = "11111111-1111-1111-1111-111111111111"
with (
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
):
mock_config.ENTERPRISE_ENABLED = True
mock_join.return_value = DefaultWorkspaceJoinResult(
workspace_id="22222222-2222-2222-2222-222222222222",
joined=True,
message="ok",
)
# Should not raise
try_join_default_workspace(account_id)
mock_join.assert_called_once_with(account_id=account_id)
def test_try_join_default_workspace_skipped_join_does_not_raise(self):
account_id = "11111111-1111-1111-1111-111111111111"
with (
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
):
mock_config.ENTERPRISE_ENABLED = True
mock_join.return_value = DefaultWorkspaceJoinResult(
workspace_id="",
joined=False,
message="no default workspace configured",
)
# Should not raise
try_join_default_workspace(account_id)
mock_join.assert_called_once_with(account_id=account_id)
def test_try_join_default_workspace_api_failure_soft_fails(self):
account_id = "11111111-1111-1111-1111-111111111111"
with (
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
):
mock_config.ENTERPRISE_ENABLED = True
mock_join.side_effect = Exception("network failure")
# Should not raise
try_join_default_workspace(account_id)
mock_join.assert_called_once_with(account_id=account_id)
def test_try_join_default_workspace_invalid_account_id_soft_fails(self):
with patch("services.enterprise.enterprise_service.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = True
# Should not raise even though UUID parsing fails inside join_default_workspace
try_join_default_workspace("not-a-uuid")

6
api/uv.lock generated
View File

@@ -5890,11 +5890,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.3"
version = "0.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
{ url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" },
]
[[package]]

View File

@@ -277,7 +277,10 @@ describe('App Card Operations Flow', () => {
}
})
// -- Basic rendering --
afterEach(() => {
vi.restoreAllMocks()
})
describe('Card Rendering', () => {
it('should render app name and description', () => {
renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' })

View File

@@ -187,7 +187,10 @@ describe('App List Browsing Flow', () => {
mockShowTagManagementModal = false
})
// -- Loading and Empty states --
afterEach(() => {
vi.restoreAllMocks()
})
describe('Loading and Empty States', () => {
it('should show skeleton cards during initial loading', () => {
mockIsLoading = true

View File

@@ -237,7 +237,6 @@ describe('Create App Flow', () => {
mockShowTagManagementModal = false
})
// -- NewAppCard rendering --
describe('NewAppCard Rendering', () => {
it('should render the "Create App" card with all options', () => {
renderList()

View File

@@ -12,7 +12,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DevelopMain from '@/app/components/develop'
import { AppModeEnum, Theme } from '@/types/app'
// ---------- fake timers ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
@@ -28,8 +27,6 @@ async function flushUI() {
})
}
// ---------- store mock ----------
let storeAppDetail: unknown
vi.mock('@/app/components/app/store', () => ({
@@ -38,8 +35,6 @@ vi.mock('@/app/components/app/store', () => ({
},
}))
// ---------- Doc dependencies ----------
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
@@ -48,11 +43,12 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: Theme.light }),
}))
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
}))
// ---------- SecretKeyModal dependencies ----------
vi.mock('@/i18n-config/language', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/i18n-config/language')>()
return {
...actual,
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({

View File

@@ -11,7 +11,7 @@ import { RETRIEVE_METHOD } from '@/types/app'
import Item from './index'
vi.mock('../settings-modal', () => ({
default: ({ onSave, onCancel, currentDataset }: any) => (
default: ({ onSave, onCancel, currentDataset }: { currentDataset: DataSet, onCancel: () => void, onSave: (newDataset: DataSet) => void }) => (
<div>
<div>Mock settings modal</div>
<button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button>
@@ -177,7 +177,7 @@ describe('dataset-config/card-item', () => {
expect(screen.getByRole('dialog')).toBeVisible()
})
await user.click(screen.getByText('Save changes'))
fireEvent.click(screen.getByText('Save changes'))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))

View File

@@ -53,6 +53,10 @@ vi.mock('@/hooks/use-theme', () => ({
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
getDocLanguage: (locale: string) => {
const map: Record<string, string> = { 'zh-Hans': 'zh', 'ja-JP': 'ja' }
return map[locale] || 'en'
},
}))
describe('Doc', () => {
@@ -63,7 +67,7 @@ describe('Doc', () => {
prompt_variables: variables,
},
},
})
}) as unknown as Parameters<typeof Doc>[0]['appDetail']
beforeEach(() => {
vi.clearAllMocks()
@@ -123,13 +127,13 @@ describe('Doc', () => {
describe('null/undefined appDetail', () => {
it('should render nothing when appDetail has no mode', () => {
render(<Doc appDetail={{}} />)
render(<Doc appDetail={{} as unknown as Parameters<typeof Doc>[0]['appDetail']} />)
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
expect(screen.queryByTestId('template-chat-en')).not.toBeInTheDocument()
})
it('should render nothing when appDetail is null', () => {
render(<Doc appDetail={null} />)
render(<Doc appDetail={null as unknown as Parameters<typeof Doc>[0]['appDetail']} />)
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,199 @@
import type { TocItem } from '../hooks/use-doc-toc'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TocPanel from '../toc-panel'
/**
* Unit tests for the TocPanel presentational component.
* Covers collapsed/expanded states, item rendering, active section, and callbacks.
*/
describe('TocPanel', () => {
const defaultProps = {
toc: [] as TocItem[],
activeSection: '',
isTocExpanded: false,
onToggle: vi.fn(),
onItemClick: vi.fn(),
}
const sampleToc: TocItem[] = [
{ href: '#introduction', text: 'Introduction' },
{ href: '#authentication', text: 'Authentication' },
{ href: '#endpoints', text: 'Endpoints' },
]
beforeEach(() => {
vi.clearAllMocks()
})
// Covers collapsed state rendering
describe('collapsed state', () => {
it('should render expand button when collapsed', () => {
render(<TocPanel {...defaultProps} />)
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
})
it('should not render nav or toc items when collapsed', () => {
render(<TocPanel {...defaultProps} toc={sampleToc} />)
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
expect(screen.queryByText('Introduction')).not.toBeInTheDocument()
})
it('should call onToggle(true) when expand button is clicked', () => {
const onToggle = vi.fn()
render(<TocPanel {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getByLabelText('Open table of contents'))
expect(onToggle).toHaveBeenCalledWith(true)
})
})
// Covers expanded state with empty toc
describe('expanded state - empty', () => {
it('should render nav with empty message when toc is empty', () => {
render(<TocPanel {...defaultProps} isTocExpanded />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
expect(screen.getByText('appApi.develop.noContent')).toBeInTheDocument()
})
it('should render TOC header with title', () => {
render(<TocPanel {...defaultProps} isTocExpanded />)
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
})
it('should call onToggle(false) when close button is clicked', () => {
const onToggle = vi.fn()
render(<TocPanel {...defaultProps} isTocExpanded onToggle={onToggle} />)
fireEvent.click(screen.getByLabelText('Close'))
expect(onToggle).toHaveBeenCalledWith(false)
})
})
// Covers expanded state with toc items
describe('expanded state - with items', () => {
it('should render all toc items as links', () => {
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
expect(screen.getByText('Introduction')).toBeInTheDocument()
expect(screen.getByText('Authentication')).toBeInTheDocument()
expect(screen.getByText('Endpoints')).toBeInTheDocument()
})
it('should render links with correct href attributes', () => {
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(3)
expect(links[0]).toHaveAttribute('href', '#introduction')
expect(links[1]).toHaveAttribute('href', '#authentication')
expect(links[2]).toHaveAttribute('href', '#endpoints')
})
it('should not render empty message when toc has items', () => {
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
expect(screen.queryByText('appApi.develop.noContent')).not.toBeInTheDocument()
})
})
// Covers active section highlighting
describe('active section', () => {
it('should apply active style to the matching toc item', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="authentication" />,
)
const activeLink = screen.getByText('Authentication').closest('a')
expect(activeLink?.className).toContain('font-medium')
expect(activeLink?.className).toContain('text-text-primary')
})
it('should apply inactive style to non-matching items', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="authentication" />,
)
const inactiveLink = screen.getByText('Introduction').closest('a')
expect(inactiveLink?.className).toContain('text-text-tertiary')
expect(inactiveLink?.className).not.toContain('font-medium')
})
it('should apply active indicator dot to active item', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="endpoints" />,
)
const activeLink = screen.getByText('Endpoints').closest('a')
const activeDot = activeLink?.querySelector('span:first-child')
expect(activeDot?.className).toContain('bg-text-accent')
})
})
// Covers click event delegation
describe('item click handling', () => {
it('should call onItemClick with the event and item when a link is clicked', () => {
const onItemClick = vi.fn()
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />,
)
fireEvent.click(screen.getByText('Authentication'))
expect(onItemClick).toHaveBeenCalledTimes(1)
expect(onItemClick).toHaveBeenCalledWith(
expect.any(Object),
{ href: '#authentication', text: 'Authentication' },
)
})
it('should call onItemClick for each clicked item independently', () => {
const onItemClick = vi.fn()
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />,
)
fireEvent.click(screen.getByText('Introduction'))
fireEvent.click(screen.getByText('Endpoints'))
expect(onItemClick).toHaveBeenCalledTimes(2)
})
})
// Covers edge cases
describe('edge cases', () => {
it('should handle single item toc', () => {
const singleItem = [{ href: '#only', text: 'Only Section' }]
render(<TocPanel {...defaultProps} isTocExpanded toc={singleItem} activeSection="only" />)
expect(screen.getByText('Only Section')).toBeInTheDocument()
expect(screen.getAllByRole('link')).toHaveLength(1)
})
it('should handle toc items with empty text', () => {
const emptyTextItem = [{ href: '#empty', text: '' }]
render(<TocPanel {...defaultProps} isTocExpanded toc={emptyTextItem} />)
expect(screen.getAllByRole('link')).toHaveLength(1)
})
it('should handle active section that does not match any item', () => {
render(
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="nonexistent" />,
)
// All items should be in inactive style
const links = screen.getAllByRole('link')
links.forEach((link) => {
expect(link.className).toContain('text-text-tertiary')
expect(link.className).not.toContain('font-medium')
})
})
})
})

View File

@@ -0,0 +1,425 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocToc } from '../hooks/use-doc-toc'
/**
* Unit tests for the useDocToc custom hook.
* Covers TOC extraction, viewport-based expansion, scroll tracking, and click handling.
*/
describe('useDocToc', () => {
const defaultOptions = { appDetail: { mode: 'chat' }, locale: 'en-US' }
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: false }),
})
})
// Covers initial state values based on viewport width
describe('initial state', () => {
it('should set isTocExpanded to false on narrow viewport', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
})
it('should set isTocExpanded to true on wide viewport', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(true)
})
})
// Covers TOC extraction from DOM article headings
describe('TOC extraction', () => {
it('should extract toc items from article h2 anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#section-1'
anchor.textContent = 'Section 1'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([
{ href: '#section-1', text: 'Section 1' },
])
expect(result.current.activeSection).toBe('section-1')
document.body.removeChild(article)
vi.useRealTimers()
})
it('should return empty toc when no article exists', async () => {
vi.useFakeTimers()
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
vi.useRealTimers()
})
it('should skip h2 headings without anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2NoAnchor = document.createElement('h2')
h2NoAnchor.textContent = 'No Anchor'
article.appendChild(h2NoAnchor)
const h2WithAnchor = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#valid'
anchor.textContent = 'Valid'
h2WithAnchor.appendChild(anchor)
article.appendChild(h2WithAnchor)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
expect(result.current.toc[0]).toEqual({ href: '#valid', text: 'Valid' })
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when appDetail changes', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
document.body.appendChild(article)
const { result, rerender } = renderHook(
props => useDocToc(props),
{ initialProps: defaultOptions },
)
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
// Add a heading, then change appDetail to trigger re-extraction
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#new-section'
anchor.textContent = 'New Section'
h2.appendChild(anchor)
article.appendChild(h2)
rerender({ appDetail: { mode: 'workflow' }, locale: 'en-US' })
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when locale changes', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#sec'
anchor.textContent = 'Sec'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result, rerender } = renderHook(
props => useDocToc(props),
{ initialProps: defaultOptions },
)
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
rerender({ appDetail: defaultOptions.appDetail, locale: 'zh-Hans' })
await act(async () => {
vi.runAllTimers()
})
// Should still have the toc item after re-extraction
expect(result.current.toc).toHaveLength(1)
document.body.removeChild(article)
vi.useRealTimers()
})
})
// Covers manual toggle via setIsTocExpanded
describe('setIsTocExpanded', () => {
it('should toggle isTocExpanded state', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
act(() => {
result.current.setIsTocExpanded(true)
})
expect(result.current.isTocExpanded).toBe(true)
act(() => {
result.current.setIsTocExpanded(false)
})
expect(result.current.isTocExpanded).toBe(false)
})
})
// Covers smooth-scroll click handler
describe('handleTocClick', () => {
it('should prevent default and scroll to target element', () => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
scrollContainer.scrollTo = vi.fn()
document.body.appendChild(scrollContainer)
const target = document.createElement('div')
target.id = 'target-section'
Object.defineProperty(target, 'offsetTop', { value: 500 })
scrollContainer.appendChild(target)
const { result } = renderHook(() => useDocToc(defaultOptions))
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
act(() => {
result.current.handleTocClick(mockEvent, { href: '#target-section', text: 'Target' })
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(scrollContainer.scrollTo).toHaveBeenCalledWith({
top: 420, // 500 - 80 (HEADER_OFFSET)
behavior: 'smooth',
})
document.body.removeChild(scrollContainer)
})
it('should do nothing when target element does not exist', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
act(() => {
result.current.handleTocClick(mockEvent, { href: '#nonexistent', text: 'Missing' })
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
})
// Covers scroll-based active section tracking
describe('scroll tracking', () => {
// Helper: set up DOM with scroll container, article headings, and matching target elements
const setupScrollDOM = (sections: Array<{ id: string, text: string, top: number }>) => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
const article = document.createElement('article')
sections.forEach(({ id, text, top }) => {
// Heading with anchor for TOC extraction
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = `#${id}`
anchor.textContent = text
h2.appendChild(anchor)
article.appendChild(h2)
// Target element for scroll tracking
const target = document.createElement('div')
target.id = id
target.getBoundingClientRect = vi.fn().mockReturnValue({ top })
scrollContainer.appendChild(target)
})
document.body.appendChild(article)
return {
scrollContainer,
article,
cleanup: () => {
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
},
}
}
it('should register scroll listener when toc has items', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'sec-a', text: 'Section A', top: 0 },
])
const addSpy = vi.spyOn(scrollContainer, 'addEventListener')
const removeSpy = vi.spyOn(scrollContainer, 'removeEventListener')
const { unmount } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
unmount()
expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
cleanup()
vi.useRealTimers()
})
it('should update activeSection when scrolling past a section', async () => {
vi.useFakeTimers()
// innerHeight/2 = 384 in jsdom (default 768), so top <= 384 means "scrolled past"
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'intro', text: 'Intro', top: 100 },
{ id: 'details', text: 'Details', top: 600 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
// Extract TOC items
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(2)
expect(result.current.activeSection).toBe('intro')
// Fire scroll — 'intro' (top=100) is above midpoint, 'details' (top=600) is below
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('intro')
cleanup()
vi.useRealTimers()
})
it('should track the last section above the viewport midpoint', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'sec-1', text: 'Section 1', top: 50 },
{ id: 'sec-2', text: 'Section 2', top: 200 },
{ id: 'sec-3', text: 'Section 3', top: 800 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
// Fire scroll — sec-1 (top=50) and sec-2 (top=200) are above midpoint (384),
// sec-3 (top=800) is below. The last one above midpoint wins.
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('sec-2')
cleanup()
vi.useRealTimers()
})
it('should not update activeSection when no section is above midpoint', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'far-away', text: 'Far Away', top: 1000 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
// Initial activeSection is set by extraction
const initialSection = result.current.activeSection
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
// Should not change since the element is below midpoint
expect(result.current.activeSection).toBe(initialSection)
cleanup()
vi.useRealTimers()
})
it('should handle elements not found in DOM during scroll', async () => {
vi.useFakeTimers()
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
// Article with heading but NO matching target element by id
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#missing-target'
anchor.textContent = 'Missing'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
const initialSection = result.current.activeSection
// Scroll fires but getElementById returns null — no crash, no change
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe(initialSection)
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
vi.useRealTimers()
})
})
})

View File

@@ -1,12 +1,13 @@
'use client'
import { RiCloseLine, RiListUnordered } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ComponentType } from 'react'
import type { App, AppSSO } from '@/types/app'
import { useMemo } from 'react'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language'
import { getDocLanguage } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import { useDocToc } from './hooks/use-doc-toc'
import TemplateEn from './template/template.en.mdx'
import TemplateJa from './template/template.ja.mdx'
import TemplateZh from './template/template.zh.mdx'
@@ -19,225 +20,75 @@ import TemplateChatZh from './template/template_chat.zh.mdx'
import TemplateWorkflowEn from './template/template_workflow.en.mdx'
import TemplateWorkflowJa from './template/template_workflow.ja.mdx'
import TemplateWorkflowZh from './template/template_workflow.zh.mdx'
import TocPanel from './toc-panel'
type AppDetail = App & Partial<AppSSO>
type PromptVariable = { key: string, name: string }
type IDocProps = {
appDetail: any
appDetail: AppDetail
}
// Shared props shape for all MDX template components
type TemplateProps = {
appDetail: AppDetail
variables: PromptVariable[]
inputs: Record<string, string>
}
// Lookup table: [appMode][docLanguage] → template component
// MDX components accept arbitrary props at runtime but expose a narrow static type,
// so we assert the map type to allow passing TemplateProps when rendering.
const TEMPLATE_MAP = {
[AppModeEnum.CHAT]: { zh: TemplateChatZh, ja: TemplateChatJa, en: TemplateChatEn },
[AppModeEnum.AGENT_CHAT]: { zh: TemplateChatZh, ja: TemplateChatJa, en: TemplateChatEn },
[AppModeEnum.ADVANCED_CHAT]: { zh: TemplateAdvancedChatZh, ja: TemplateAdvancedChatJa, en: TemplateAdvancedChatEn },
[AppModeEnum.WORKFLOW]: { zh: TemplateWorkflowZh, ja: TemplateWorkflowJa, en: TemplateWorkflowEn },
[AppModeEnum.COMPLETION]: { zh: TemplateZh, ja: TemplateJa, en: TemplateEn },
} as Record<string, Record<string, ComponentType<TemplateProps>>>
const resolveTemplate = (mode: string | undefined, locale: string): ComponentType<TemplateProps> | null => {
if (!mode)
return null
const langTemplates = TEMPLATE_MAP[mode]
if (!langTemplates)
return null
const docLang = getDocLanguage(locale)
return langTemplates[docLang] ?? langTemplates.en ?? null
}
const Doc = ({ appDetail }: IDocProps) => {
const locale = useLocale()
const { t } = useTranslation()
const [toc, setToc] = useState<Array<{ href: string, text: string }>>([])
const [isTocExpanded, setIsTocExpanded] = useState(false)
const [activeSection, setActiveSection] = useState<string>('')
const { theme } = useTheme()
const { toc, isTocExpanded, setIsTocExpanded, activeSection, handleTocClick } = useDocToc({ appDetail, locale })
const variables = appDetail?.model_config?.configs?.prompt_variables || []
const inputs = variables.reduce((res: any, variable: any) => {
// model_config.configs.prompt_variables exists in the raw API response but is not modeled in ModelConfig type
const variables: PromptVariable[] = (
appDetail?.model_config as unknown as Record<string, Record<string, PromptVariable[]>> | undefined
)?.configs?.prompt_variables ?? []
const inputs = variables.reduce<Record<string, string>>((res, variable) => {
res[variable.key] = variable.name || ''
return res
}, {})
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
}, [])
useEffect(() => {
const extractTOC = () => {
const article = document.querySelector('article')
if (article) {
const headings = article.querySelectorAll('h2')
const tocItems = Array.from(headings).map((heading) => {
const anchor = heading.querySelector('a')
if (anchor) {
return {
href: anchor.getAttribute('href') || '',
text: anchor.textContent || '',
}
}
return null
}).filter((item): item is { href: string, text: string } => item !== null)
setToc(tocItems)
if (tocItems.length > 0)
setActiveSection(tocItems[0].href.replace('#', ''))
}
}
setTimeout(extractTOC, 0)
}, [appDetail, locale])
useEffect(() => {
const handleScroll = () => {
const scrollContainer = document.querySelector('.overflow-auto')
if (!scrollContainer || toc.length === 0)
return
let currentSection = ''
toc.forEach((item) => {
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId)
if (element) {
const rect = element.getBoundingClientRect()
if (rect.top <= window.innerHeight / 2)
currentSection = targetId
}
})
if (currentSection && currentSection !== activeSection)
setActiveSection(currentSection)
}
const scrollContainer = document.querySelector('.overflow-auto')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', handleScroll)
handleScroll()
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [toc, activeSection])
const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string, text: string }) => {
e.preventDefault()
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId)
if (element) {
const scrollContainer = document.querySelector('.overflow-auto')
if (scrollContainer) {
const headerOffset = 80
const elementTop = element.offsetTop - headerOffset
scrollContainer.scrollTo({
top: elementTop,
behavior: 'smooth',
})
}
}
}
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT || appDetail?.mode === AppModeEnum.AGENT_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateWorkflowZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateWorkflowJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateWorkflowEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === AppModeEnum.COMPLETION) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
return null
}, [appDetail, locale, variables, inputs])
const TemplateComponent = useMemo(
() => resolveTemplate(appDetail?.mode, locale),
[appDetail?.mode, locale],
)
return (
<div className="flex">
<div className={`fixed right-20 top-32 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`}>
{isTocExpanded
? (
<nav className="toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl">
<div className="relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5">
<span className="text-xs font-medium uppercase tracking-wide text-text-tertiary">
{t('develop.toc', { ns: 'appApi' })}
</span>
<button
type="button"
onClick={() => setIsTocExpanded(false)}
className="group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover"
aria-label="Close"
>
<RiCloseLine className="h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary" />
</button>
</div>
<div className="from-components-panel-border-subtle/20 pointer-events-none absolute left-0 right-0 top-[41px] z-10 h-2 bg-gradient-to-b to-transparent"></div>
<div className="pointer-events-none absolute left-0 right-0 top-[43px] z-10 h-3 bg-gradient-to-b from-background-default-hover to-transparent"></div>
<div className="relative flex-1 overflow-y-auto px-3 py-3 pt-1">
{toc.length === 0
? (
<div className="px-2 py-8 text-center text-xs text-text-quaternary">
{t('develop.noContent', { ns: 'appApi' })}
</div>
)
: (
<ul className="space-y-0.5">
{toc.map((item, index) => {
const isActive = activeSection === item.href.replace('#', '')
return (
<li key={index}>
<a
href={item.href}
onClick={e => handleTocClick(e, item)}
className={cn(
'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200',
isActive
? 'bg-state-base-hover font-medium text-text-primary'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span
className={cn(
'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200',
isActive
? 'scale-100 bg-text-accent'
: 'scale-75 bg-components-panel-border',
)}
/>
<span className="flex-1 truncate">
{item.text}
</span>
</a>
</li>
)
})}
</ul>
)}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-4 rounded-b-xl bg-gradient-to-t from-background-default-hover to-transparent"></div>
</nav>
)
: (
<button
type="button"
onClick={() => setIsTocExpanded(true)}
className="group flex h-11 w-11 items-center justify-center rounded-full border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-all duration-150 hover:bg-background-default-hover hover:shadow-xl"
aria-label="Open table of contents"
>
<RiListUnordered className="h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary" />
</button>
)}
<TocPanel
toc={toc}
activeSection={activeSection}
isTocExpanded={isTocExpanded}
onToggle={setIsTocExpanded}
onItemClick={handleTocClick}
/>
</div>
<article className={cn('prose-xl prose', theme === Theme.dark && 'prose-invert')}>
{Template}
{TemplateComponent && <TemplateComponent appDetail={appDetail} variables={variables} inputs={inputs} />}
</article>
</div>
)

View File

@@ -0,0 +1,115 @@
import { useCallback, useEffect, useState } from 'react'
export type TocItem = {
href: string
text: string
}
type UseDocTocOptions = {
appDetail: Record<string, unknown> | null
locale: string
}
const HEADER_OFFSET = 80
const SCROLL_CONTAINER_SELECTOR = '.overflow-auto'
const getTargetId = (href: string) => href.replace('#', '')
/**
* Extract heading anchors from the rendered <article> as TOC items.
*/
const extractTocFromArticle = (): TocItem[] => {
const article = document.querySelector('article')
if (!article)
return []
return Array.from(article.querySelectorAll('h2'))
.map((heading) => {
const anchor = heading.querySelector('a')
if (!anchor)
return null
return {
href: anchor.getAttribute('href') || '',
text: anchor.textContent || '',
}
})
.filter((item): item is TocItem => item !== null)
}
/**
* Custom hook that manages table-of-contents state:
* - Extracts TOC items from rendered headings
* - Tracks the active section on scroll
* - Auto-expands the panel on wide viewports
*/
export const useDocToc = ({ appDetail, locale }: UseDocTocOptions) => {
const [toc, setToc] = useState<TocItem[]>([])
const [isTocExpanded, setIsTocExpanded] = useState(() => {
if (typeof window === 'undefined')
return false
return window.matchMedia('(min-width: 1280px)').matches
})
const [activeSection, setActiveSection] = useState<string>('')
// Re-extract TOC items whenever the doc content changes
useEffect(() => {
const timer = setTimeout(() => {
const tocItems = extractTocFromArticle()
setToc(tocItems)
if (tocItems.length > 0)
setActiveSection(getTargetId(tocItems[0].href))
}, 0)
return () => clearTimeout(timer)
}, [appDetail, locale])
// Track active section based on scroll position
useEffect(() => {
const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR)
if (!scrollContainer || toc.length === 0)
return
const handleScroll = () => {
let currentSection = ''
for (const item of toc) {
const targetId = getTargetId(item.href)
const element = document.getElementById(targetId)
if (element) {
const rect = element.getBoundingClientRect()
if (rect.top <= window.innerHeight / 2)
currentSection = targetId
}
}
if (currentSection && currentSection !== activeSection)
setActiveSection(currentSection)
}
scrollContainer.addEventListener('scroll', handleScroll)
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [toc, activeSection])
// Smooth-scroll to a TOC target on click
const handleTocClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: TocItem) => {
e.preventDefault()
const targetId = getTargetId(item.href)
const element = document.getElementById(targetId)
if (!element)
return
const scrollContainer = document.querySelector(SCROLL_CONTAINER_SELECTOR)
if (scrollContainer) {
scrollContainer.scrollTo({
top: element.offsetTop - HEADER_OFFSET,
behavior: 'smooth',
})
}
}, [])
return {
toc,
isTocExpanded,
setIsTocExpanded,
activeSection,
handleTocClick,
}
}

View File

@@ -0,0 +1,96 @@
'use client'
import type { TocItem } from './hooks/use-doc-toc'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type TocPanelProps = {
toc: TocItem[]
activeSection: string
isTocExpanded: boolean
onToggle: (expanded: boolean) => void
onItemClick: (e: React.MouseEvent<HTMLAnchorElement>, item: TocItem) => void
}
const TocPanel = ({ toc, activeSection, isTocExpanded, onToggle, onItemClick }: TocPanelProps) => {
const { t } = useTranslation()
if (!isTocExpanded) {
return (
<button
type="button"
onClick={() => onToggle(true)}
className="group flex h-11 w-11 items-center justify-center rounded-full border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-all duration-150 hover:bg-background-default-hover hover:shadow-xl"
aria-label="Open table of contents"
>
<span className="i-ri-list-unordered h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary" />
</button>
)
}
return (
<nav className="toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl">
<div className="relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5">
<span className="text-xs font-medium uppercase tracking-wide text-text-tertiary">
{t('develop.toc', { ns: 'appApi' })}
</span>
<button
type="button"
onClick={() => onToggle(false)}
className="group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover"
aria-label="Close"
>
<span className="i-ri-close-line h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary" />
</button>
</div>
<div className="from-components-panel-border-subtle/20 pointer-events-none absolute left-0 right-0 top-[41px] z-10 h-2 bg-gradient-to-b to-transparent"></div>
<div className="pointer-events-none absolute left-0 right-0 top-[43px] z-10 h-3 bg-gradient-to-b from-background-default-hover to-transparent"></div>
<div className="relative flex-1 overflow-y-auto px-3 py-3 pt-1">
{toc.length === 0
? (
<div className="px-2 py-8 text-center text-xs text-text-quaternary">
{t('develop.noContent', { ns: 'appApi' })}
</div>
)
: (
<ul className="space-y-0.5">
{toc.map((item) => {
const isActive = activeSection === item.href.replace('#', '')
return (
<li key={item.href}>
<a
href={item.href}
onClick={e => onItemClick(e, item)}
className={cn(
'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200',
isActive
? 'bg-state-base-hover font-medium text-text-primary'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span
className={cn(
'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200',
isActive
? 'scale-100 bg-text-accent'
: 'scale-75 bg-components-panel-border',
)}
/>
<span className="flex-1 truncate">
{item.text}
</span>
</a>
</li>
)
})}
</ul>
)}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-4 rounded-b-xl bg-gradient-to-t from-background-default-hover to-transparent"></div>
</nav>
)
}
export default TocPanel

View File

@@ -61,8 +61,8 @@ vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({}),
}))
// Mock pluginInstallLimit
vi.mock('../../../hooks/use-install-plugin-limit', () => ({
// Mock pluginInstallLimit (imported by the useInstallMultiState hook via @/ path)
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: true }),
}))

View File

@@ -0,0 +1,568 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getPluginKey, useInstallMultiState } from '../use-install-multi-state'
let mockMarketplaceData: ReturnType<typeof createMarketplaceApiData> | null = null
let mockMarketplaceError: Error | null = null
let mockInstalledInfo: Record<string, VersionInfo> = {}
let mockCanInstall = true
vi.mock('@/service/use-plugins', () => ({
useFetchPluginsInMarketPlaceByInfo: () => ({
isLoading: false,
data: mockMarketplaceData,
error: mockMarketplaceError,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({
installedInfo: mockInstalledInfo,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: mockCanInstall }),
}))
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-pkg-id',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Test Plugin' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createPackageDependency = (index: number) => ({
type: 'package',
value: {
unique_identifier: `package-plugin-${index}-uid`,
manifest: {
plugin_unique_identifier: `package-plugin-${index}-uid`,
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: `Package Plugin ${index}`,
category: PluginCategoryEnum.tool,
label: { 'en-US': `Package Plugin ${index}` },
description: { 'en-US': 'Test package plugin' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {},
},
},
} as unknown as PackageDependency)
const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: `test-org/plugin-${index}:1.0.0`,
plugin_unique_identifier: `plugin-${index}`,
version: '1.0.0',
},
})
const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'github',
value: {
repo: `test-org/plugin-${index}`,
version: 'v1.0.0',
package: `plugin-${index}.zip`,
},
})
const createMarketplaceApiData = (indexes: number[]) => ({
data: {
list: indexes.map(i => ({
plugin: {
plugin_id: `test-org/plugin-${i}`,
org: 'test-org',
name: `Test Plugin ${i}`,
version: '1.0.0',
latest_version: '1.0.0',
},
version: {
unique_identifier: `plugin-${i}-uid`,
},
})),
},
})
const createDefaultParams = (overrides = {}) => ({
allPlugins: [createPackageDependency(0)] as Dependency[],
selectedPlugins: [] as Plugin[],
onSelect: vi.fn(),
onLoadedAllPlugin: vi.fn(),
...overrides,
})
// ==================== getPluginKey Tests ====================
describe('getPluginKey', () => {
it('should return org/name when org is available', () => {
const plugin = createMockPlugin({ org: 'my-org', name: 'my-plugin' })
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
})
it('should fall back to author when org is not available', () => {
const plugin = createMockPlugin({ org: undefined, author: 'my-author', name: 'my-plugin' })
expect(getPluginKey(plugin)).toBe('my-author/my-plugin')
})
it('should prefer org over author when both exist', () => {
const plugin = createMockPlugin({ org: 'my-org', author: 'my-author', name: 'my-plugin' })
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
})
it('should handle undefined plugin', () => {
expect(getPluginKey(undefined)).toBe('undefined/undefined')
})
})
// ==================== useInstallMultiState Tests ====================
describe('useInstallMultiState', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMarketplaceData = null
mockMarketplaceError = null
mockInstalledInfo = {}
mockCanInstall = true
})
// ==================== Initial State ====================
describe('Initial State', () => {
it('should initialize plugins from package dependencies', () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(1)
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[0]?.plugin_id).toBe('package-plugin-0-uid')
})
it('should have slots for all dependencies even when no packages exist', () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
// Array has slots for all dependencies, but unresolved ones are undefined
expect(result.current.plugins).toHaveLength(1)
expect(result.current.plugins[0]).toBeUndefined()
})
it('should return undefined for non-package items in mixed dependencies', () => {
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[1]).toBeUndefined()
})
it('should start with empty errorIndexes', () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.errorIndexes).toEqual([])
})
})
// ==================== Marketplace Data Sync ====================
describe('Marketplace Data Sync', () => {
it('should update plugins when marketplace data loads by ID', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[0]?.version).toBe('1.0.0')
})
})
it('should update plugins when marketplace data loads by meta', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
// The "by meta" effect sets plugin_id from version.unique_identifier
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
})
})
it('should add to errorIndexes when marketplace item not found in response', async () => {
mockMarketplaceData = { data: { list: [] } }
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should handle multiple marketplace plugins', async () => {
mockMarketplaceData = createMarketplaceApiData([0, 1])
const params = createDefaultParams({
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[1]).toBeDefined()
})
})
})
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should mark all marketplace indexes as errors on fetch failure', async () => {
mockMarketplaceError = new Error('Fetch failed')
const params = createDefaultParams({
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
expect(result.current.errorIndexes).toContain(1)
})
})
it('should not affect non-marketplace indexes on marketplace fetch error', async () => {
mockMarketplaceError = new Error('Fetch failed')
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(1)
expect(result.current.errorIndexes).not.toContain(0)
})
})
})
// ==================== Loaded All Data Notification ====================
describe('Loaded All Data Notification', () => {
it('should call onLoadedAllPlugin when all data loaded', async () => {
const params = createDefaultParams()
renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(params.onLoadedAllPlugin).toHaveBeenCalledWith(mockInstalledInfo)
})
})
it('should not call onLoadedAllPlugin when not all plugins resolved', () => {
// GitHub plugin not fetched yet → isLoadedAllData = false
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
renderHook(() => useInstallMultiState(params))
expect(params.onLoadedAllPlugin).not.toHaveBeenCalled()
})
it('should call onLoadedAllPlugin after all errors are counted', async () => {
mockMarketplaceError = new Error('Fetch failed')
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
renderHook(() => useInstallMultiState(params))
// Error fills errorIndexes → isLoadedAllData becomes true
await waitFor(() => {
expect(params.onLoadedAllPlugin).toHaveBeenCalled()
})
})
})
// ==================== handleGitHubPluginFetched ====================
describe('handleGitHubPluginFetched', () => {
it('should update plugin at the specified index', async () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-0' })
await act(async () => {
result.current.handleGitHubPluginFetched(0)(mockPlugin)
})
expect(result.current.plugins[0]).toEqual(mockPlugin)
})
it('should not affect other plugin slots', async () => {
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const originalPlugin0 = result.current.plugins[0]
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-1' })
await act(async () => {
result.current.handleGitHubPluginFetched(1)(mockPlugin)
})
expect(result.current.plugins[0]).toEqual(originalPlugin0)
expect(result.current.plugins[1]).toEqual(mockPlugin)
})
})
// ==================== handleGitHubPluginFetchError ====================
describe('handleGitHubPluginFetchError', () => {
it('should add index to errorIndexes', async () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleGitHubPluginFetchError(0)()
})
expect(result.current.errorIndexes).toContain(0)
})
it('should accumulate multiple error indexes without stale closure', async () => {
const params = createDefaultParams({
allPlugins: [
createGitHubDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleGitHubPluginFetchError(0)()
})
await act(async () => {
result.current.handleGitHubPluginFetchError(1)()
})
expect(result.current.errorIndexes).toContain(0)
expect(result.current.errorIndexes).toContain(1)
})
})
// ==================== getVersionInfo ====================
describe('getVersionInfo', () => {
it('should return hasInstalled false when plugin not installed', () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
const info = result.current.getVersionInfo('unknown/plugin')
expect(info.hasInstalled).toBe(false)
expect(info.installedVersion).toBeUndefined()
expect(info.toInstallVersion).toBe('')
})
it('should return hasInstalled true with version when installed', () => {
mockInstalledInfo = {
'test-author/Package Plugin 0': {
installedId: 'installed-1',
installedVersion: '0.9.0',
uniqueIdentifier: 'uid-1',
},
}
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
const info = result.current.getVersionInfo('test-author/Package Plugin 0')
expect(info.hasInstalled).toBe(true)
expect(info.installedVersion).toBe('0.9.0')
})
})
// ==================== handleSelect ====================
describe('handleSelect', () => {
it('should call onSelect with plugin, index, and installable count', async () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleSelect(0)()
})
expect(params.onSelect).toHaveBeenCalledWith(
result.current.plugins[0],
0,
expect.any(Number),
)
})
it('should filter installable plugins using pluginInstallLimit', async () => {
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleSelect(0)()
})
// mockCanInstall is true, so all 2 plugins are installable
expect(params.onSelect).toHaveBeenCalledWith(
expect.anything(),
0,
2,
)
})
})
// ==================== isPluginSelected ====================
describe('isPluginSelected', () => {
it('should return true when plugin is in selectedPlugins', () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'package-plugin-0-uid' })
const params = createDefaultParams({
selectedPlugins: [selectedPlugin],
})
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.isPluginSelected(0)).toBe(true)
})
it('should return false when plugin is not in selectedPlugins', () => {
const params = createDefaultParams({ selectedPlugins: [] })
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.isPluginSelected(0)).toBe(false)
})
it('should return false when plugin at index is undefined', () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
selectedPlugins: [createMockPlugin()],
})
const { result } = renderHook(() => useInstallMultiState(params))
// plugins[0] is undefined (GitHub not yet fetched)
expect(result.current.isPluginSelected(0)).toBe(false)
})
})
// ==================== getInstallablePlugins ====================
describe('getInstallablePlugins', () => {
it('should return all plugins when canInstall is true', () => {
mockCanInstall = true
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
expect(installablePlugins).toHaveLength(2)
expect(selectedIndexes).toEqual([0, 1])
})
it('should return empty arrays when canInstall is false', () => {
mockCanInstall = false
const params = createDefaultParams({
allPlugins: [createPackageDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
expect(installablePlugins).toHaveLength(0)
expect(selectedIndexes).toEqual([])
})
it('should skip unloaded (undefined) plugins', () => {
mockCanInstall = true
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
// Only package plugin is loaded; GitHub not yet fetched
expect(installablePlugins).toHaveLength(1)
expect(selectedIndexes).toEqual([0])
})
})
})

View File

@@ -0,0 +1,230 @@
'use client'
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
type UseInstallMultiStateParams = {
allPlugins: Dependency[]
selectedPlugins: Plugin[]
onSelect: (plugin: Plugin, selectedIndex: number, allCanInstallPluginsLength: number) => void
onLoadedAllPlugin: (installedInfo: Record<string, VersionInfo>) => void
}
export function getPluginKey(plugin: Plugin | undefined): string {
return `${plugin?.org || plugin?.author}/${plugin?.name}`
}
function parseMarketplaceIdentifier(identifier: string) {
const [orgPart, nameAndVersionPart] = identifier.split('@')[0].split('/')
const [name, version] = nameAndVersionPart.split(':')
return { organization: orgPart, plugin: name, version }
}
function initPluginsFromDependencies(allPlugins: Dependency[]): (Plugin | undefined)[] {
if (!allPlugins.some(d => d.type === 'package'))
return []
return allPlugins.map((d) => {
if (d.type !== 'package')
return undefined
const { manifest, unique_identifier } = (d as PackageDependency).value
return {
...manifest,
plugin_id: unique_identifier,
} as unknown as Plugin
})
}
export function useInstallMultiState({
allPlugins,
selectedPlugins,
onSelect,
onLoadedAllPlugin,
}: UseInstallMultiStateParams) {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
// Marketplace plugins filtering and index mapping
const marketplacePlugins = useMemo(
() => allPlugins.filter((d): d is GitHubItemAndMarketPlaceDependency => d.type === 'marketplace'),
[allPlugins],
)
const marketPlaceInDSLIndex = useMemo(() => {
return allPlugins.reduce<number[]>((acc, d, index) => {
if (d.type === 'marketplace')
acc.push(index)
return acc
}, [])
}, [allPlugins])
// Marketplace data fetching: by unique identifier and by meta info
const {
isLoading: isFetchingById,
data: infoGetById,
error: infoByIdError,
} = useFetchPluginsInMarketPlaceByInfo(
marketplacePlugins.map(d => parseMarketplaceIdentifier(d.value.marketplace_plugin_unique_identifier!)),
)
const {
isLoading: isFetchingByMeta,
data: infoByMeta,
error: infoByMetaError,
} = useFetchPluginsInMarketPlaceByInfo(
marketplacePlugins.map(d => d.value!),
)
// Derive marketplace plugin data and errors from API responses
const { marketplacePluginMap, marketplaceErrorIndexes } = useMemo(() => {
const pluginMap = new Map<number, Plugin>()
const errorSet = new Set<number>()
// Process "by ID" response
if (!isFetchingById && infoGetById?.data.list) {
const sortedList = marketplacePlugins.map((d) => {
const id = d.value.marketplace_plugin_unique_identifier?.split(':')[0]
const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin
return { ...retPluginInfo, from: d.type } as Plugin
})
marketPlaceInDSLIndex.forEach((index, i) => {
if (sortedList[i]) {
pluginMap.set(index, {
...sortedList[i],
version: sortedList[i]!.version || sortedList[i]!.latest_version,
})
}
else { errorSet.add(index) }
})
}
// Process "by meta" response (may overwrite "by ID" results)
if (!isFetchingByMeta && infoByMeta?.data.list) {
const payloads = infoByMeta.data.list
marketPlaceInDSLIndex.forEach((index, i) => {
if (payloads[i]) {
const item = payloads[i]
pluginMap.set(index, {
...item.plugin,
plugin_id: item.version.unique_identifier,
} as Plugin)
}
else { errorSet.add(index) }
})
}
// Mark all marketplace indexes as errors on fetch failure
if (infoByMetaError || infoByIdError)
marketPlaceInDSLIndex.forEach(index => errorSet.add(index))
return { marketplacePluginMap: pluginMap, marketplaceErrorIndexes: errorSet }
}, [isFetchingById, isFetchingByMeta, infoGetById, infoByMeta, infoByMetaError, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins])
// GitHub-fetched plugins and errors (imperative state from child callbacks)
const [githubPluginMap, setGithubPluginMap] = useState<Map<number, Plugin>>(() => new Map())
const [githubErrorIndexes, setGithubErrorIndexes] = useState<number[]>([])
// Merge all plugin sources into a single array
const plugins = useMemo(() => {
const initial = initPluginsFromDependencies(allPlugins)
const result: (Plugin | undefined)[] = allPlugins.map((_, i) => initial[i])
marketplacePluginMap.forEach((plugin, index) => {
result[index] = plugin
})
githubPluginMap.forEach((plugin, index) => {
result[index] = plugin
})
return result
}, [allPlugins, marketplacePluginMap, githubPluginMap])
// Merge all error sources
const errorIndexes = useMemo(() => {
return [...marketplaceErrorIndexes, ...githubErrorIndexes]
}, [marketplaceErrorIndexes, githubErrorIndexes])
// Check installed status after all data is loaded
const isLoadedAllData = (plugins.filter(Boolean).length + errorIndexes.length) === allPlugins.length
const { installedInfo } = useCheckInstalled({
pluginIds: plugins.filter(Boolean).map(d => getPluginKey(d)) || [],
enabled: isLoadedAllData,
})
// Notify parent when all plugin data and install info is ready
useEffect(() => {
if (isLoadedAllData && installedInfo)
onLoadedAllPlugin(installedInfo!)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadedAllData, installedInfo])
// Callback: handle GitHub plugin fetch success
const handleGitHubPluginFetched = useCallback((index: number) => {
return (p: Plugin) => {
setGithubPluginMap(prev => new Map(prev).set(index, p))
}
}, [])
// Callback: handle GitHub plugin fetch error
const handleGitHubPluginFetchError = useCallback((index: number) => {
return () => {
setGithubErrorIndexes(prev => [...prev, index])
}
}, [])
// Callback: get version info for a plugin by its key
const getVersionInfo = useCallback((pluginId: string) => {
const pluginDetail = installedInfo?.[pluginId]
return {
hasInstalled: !!pluginDetail,
installedVersion: pluginDetail?.installedVersion,
toInstallVersion: '',
}
}, [installedInfo])
// Callback: handle plugin selection
const handleSelect = useCallback((index: number) => {
return () => {
const canSelectPlugins = plugins.filter((p) => {
const { canInstall } = pluginInstallLimit(p!, systemFeatures)
return canInstall
})
onSelect(plugins[index]!, index, canSelectPlugins.length)
}
}, [onSelect, plugins, systemFeatures])
// Callback: check if a plugin at given index is selected
const isPluginSelected = useCallback((index: number) => {
return !!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)
}, [selectedPlugins, plugins])
// Callback: get all installable plugins with their indexes
const getInstallablePlugins = useCallback(() => {
const selectedIndexes: number[] = []
const installablePlugins: Plugin[] = []
allPlugins.forEach((_d, index) => {
const p = plugins[index]
if (!p)
return
const { canInstall } = pluginInstallLimit(p, systemFeatures)
if (canInstall) {
selectedIndexes.push(index)
installablePlugins.push(p)
}
})
return { selectedIndexes, installablePlugins }
}, [allPlugins, plugins, systemFeatures])
return {
plugins,
errorIndexes,
handleGitHubPluginFetched,
handleGitHubPluginFetchError,
getVersionInfo,
handleSelect,
isPluginSelected,
getInstallablePlugins,
}
}

View File

@@ -1,16 +1,12 @@
'use client'
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
import { useImperativeHandle } from 'react'
import LoadingError from '../../base/loading-error'
import { pluginInstallLimit } from '../../hooks/use-install-plugin-limit'
import GithubItem from '../item/github-item'
import MarketplaceItem from '../item/marketplace-item'
import PackageItem from '../item/package-item'
import { getPluginKey, useInstallMultiState } from './hooks/use-install-multi-state'
type Props = {
allPlugins: Dependency[]
@@ -38,206 +34,50 @@ const InstallByDSLList = ({
isFromMarketPlace,
ref,
}: Props) => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
// DSL has id, to get plugin info to show more info
const { isLoading: isFetchingMarketplaceDataById, data: infoGetById, error: infoByIdError } = useFetchPluginsInMarketPlaceByInfo(allPlugins.filter(d => d.type === 'marketplace').map((d) => {
const dependecy = (d as GitHubItemAndMarketPlaceDependency).value
// split org, name, version by / and :
// and remove @ and its suffix
const [orgPart, nameAndVersionPart] = dependecy.marketplace_plugin_unique_identifier!.split('@')[0].split('/')
const [name, version] = nameAndVersionPart.split(':')
return {
organization: orgPart,
plugin: name,
version,
}
}))
// has meta(org,name,version), to get id
const { isLoading: isFetchingDataByMeta, data: infoByMeta, error: infoByMetaError } = useFetchPluginsInMarketPlaceByInfo(allPlugins.filter(d => d.type === 'marketplace').map(d => (d as GitHubItemAndMarketPlaceDependency).value!))
const [plugins, doSetPlugins] = useState<(Plugin | undefined)[]>((() => {
const hasLocalPackage = allPlugins.some(d => d.type === 'package')
if (!hasLocalPackage)
return []
const _plugins = allPlugins.map((d) => {
if (d.type === 'package') {
return {
...(d as any).value.manifest,
plugin_id: (d as any).value.unique_identifier,
}
}
return undefined
})
return _plugins
})())
const pluginsRef = React.useRef<(Plugin | undefined)[]>(plugins)
const setPlugins = useCallback((p: (Plugin | undefined)[]) => {
doSetPlugins(p)
pluginsRef.current = p
}, [])
const [errorIndexes, setErrorIndexes] = useState<number[]>([])
const handleGitHubPluginFetched = useCallback((index: number) => {
return (p: Plugin) => {
const nextPlugins = produce(pluginsRef.current, (draft) => {
draft[index] = p
})
setPlugins(nextPlugins)
}
}, [setPlugins])
const handleGitHubPluginFetchError = useCallback((index: number) => {
return () => {
setErrorIndexes([...errorIndexes, index])
}
}, [errorIndexes])
const marketPlaceInDSLIndex = useMemo(() => {
const res: number[] = []
allPlugins.forEach((d, index) => {
if (d.type === 'marketplace')
res.push(index)
})
return res
}, [allPlugins])
useEffect(() => {
if (!isFetchingMarketplaceDataById && infoGetById?.data.list) {
const sortedList = allPlugins.filter(d => d.type === 'marketplace').map((d) => {
const p = d as GitHubItemAndMarketPlaceDependency
const id = p.value.marketplace_plugin_unique_identifier?.split(':')[0]
const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin
return { ...retPluginInfo, from: d.type } as Plugin
})
const payloads = sortedList
const failedIndex: number[] = []
const nextPlugins = produce(pluginsRef.current, (draft) => {
marketPlaceInDSLIndex.forEach((index, i) => {
if (payloads[i]) {
draft[index] = {
...payloads[i],
version: payloads[i]!.version || payloads[i]!.latest_version,
}
}
else { failedIndex.push(index) }
})
})
setPlugins(nextPlugins)
if (failedIndex.length > 0)
setErrorIndexes([...errorIndexes, ...failedIndex])
}
}, [isFetchingMarketplaceDataById])
useEffect(() => {
if (!isFetchingDataByMeta && infoByMeta?.data.list) {
const payloads = infoByMeta?.data.list
const failedIndex: number[] = []
const nextPlugins = produce(pluginsRef.current, (draft) => {
marketPlaceInDSLIndex.forEach((index, i) => {
if (payloads[i]) {
const item = payloads[i]
draft[index] = {
...item.plugin,
plugin_id: item.version.unique_identifier,
}
}
else {
failedIndex.push(index)
}
})
})
setPlugins(nextPlugins)
if (failedIndex.length > 0)
setErrorIndexes([...errorIndexes, ...failedIndex])
}
}, [isFetchingDataByMeta])
useEffect(() => {
// get info all failed
if (infoByMetaError || infoByIdError)
setErrorIndexes([...errorIndexes, ...marketPlaceInDSLIndex])
}, [infoByMetaError, infoByIdError])
const isLoadedAllData = (plugins.filter(p => !!p).length + errorIndexes.length) === allPlugins.length
const { installedInfo } = useCheckInstalled({
pluginIds: plugins?.filter(p => !!p).map((d) => {
return `${d?.org || d?.author}/${d?.name}`
}) || [],
enabled: isLoadedAllData,
const {
plugins,
errorIndexes,
handleGitHubPluginFetched,
handleGitHubPluginFetchError,
getVersionInfo,
handleSelect,
isPluginSelected,
getInstallablePlugins,
} = useInstallMultiState({
allPlugins,
selectedPlugins,
onSelect,
onLoadedAllPlugin,
})
const getVersionInfo = useCallback((pluginId: string) => {
const pluginDetail = installedInfo?.[pluginId]
const hasInstalled = !!pluginDetail
return {
hasInstalled,
installedVersion: pluginDetail?.installedVersion,
toInstallVersion: '',
}
}, [installedInfo])
useEffect(() => {
if (isLoadedAllData && installedInfo)
onLoadedAllPlugin(installedInfo!)
}, [isLoadedAllData, installedInfo])
const handleSelect = useCallback((index: number) => {
return () => {
const canSelectPlugins = plugins.filter((p) => {
const { canInstall } = pluginInstallLimit(p!, systemFeatures)
return canInstall
})
onSelect(plugins[index]!, index, canSelectPlugins.length)
}
}, [onSelect, plugins, systemFeatures])
useImperativeHandle(ref, () => ({
selectAllPlugins: () => {
const selectedIndexes: number[] = []
const selectedPlugins: Plugin[] = []
allPlugins.forEach((d, index) => {
const p = plugins[index]
if (!p)
return
const { canInstall } = pluginInstallLimit(p, systemFeatures)
if (canInstall) {
selectedIndexes.push(index)
selectedPlugins.push(p)
}
})
onSelectAll(selectedPlugins, selectedIndexes)
},
deSelectAllPlugins: () => {
onDeSelectAll()
const { installablePlugins, selectedIndexes } = getInstallablePlugins()
onSelectAll(installablePlugins, selectedIndexes)
},
deSelectAllPlugins: onDeSelectAll,
}))
return (
<>
{allPlugins.map((d, index) => {
if (errorIndexes.includes(index)) {
return (
<LoadingError key={index} />
)
}
if (errorIndexes.includes(index))
return <LoadingError key={index} />
const plugin = plugins[index]
const checked = isPluginSelected(index)
const versionInfo = getVersionInfo(getPluginKey(plugin))
if (d.type === 'github') {
return (
<GithubItem
key={index}
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
checked={checked}
onCheckedChange={handleSelect(index)}
dependency={d as GitHubItemAndMarketPlaceDependency}
onFetchedPayload={handleGitHubPluginFetched(index)}
onFetchError={handleGitHubPluginFetchError(index)}
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
versionInfo={versionInfo}
/>
)
}
@@ -246,24 +86,23 @@ const InstallByDSLList = ({
return (
<MarketplaceItem
key={index}
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
checked={checked}
onCheckedChange={handleSelect(index)}
payload={{ ...plugin, from: d.type } as Plugin}
version={(d as GitHubItemAndMarketPlaceDependency).value.version! || plugin?.version || ''}
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
versionInfo={versionInfo}
/>
)
}
// Local package
return (
<PackageItem
key={index}
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
checked={checked}
onCheckedChange={handleSelect(index)}
payload={d as PackageDependency}
isFromMarketPlace={isFromMarketPlace}
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
versionInfo={versionInfo}
/>
)
})}

View File

@@ -30,19 +30,21 @@ vi.mock('@/context/app-context', () => ({
}))
// Mock API services - only mock external services
const mockFetchWorkflowToolDetailByAppID = vi.fn()
const mockCreateWorkflowToolProvider = vi.fn()
const mockSaveWorkflowToolProvider = vi.fn()
vi.mock('@/service/tools', () => ({
fetchWorkflowToolDetailByAppID: (...args: unknown[]) => mockFetchWorkflowToolDetailByAppID(...args),
createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args),
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
}))
// Mock invalidate workflow tools hook
// Mock service hooks
const mockInvalidateAllWorkflowTools = vi.fn()
const mockInvalidateWorkflowToolDetailByAppID = vi.fn()
const mockUseWorkflowToolDetailByAppID = vi.fn()
vi.mock('@/service/use-tools', () => ({
useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools,
useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID,
useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args),
}))
// Mock Toast - need to verify notification calls
@@ -242,7 +244,10 @@ describe('WorkflowToolConfigureButton', () => {
vi.clearAllMocks()
mockPortalOpenState = false
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail())
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
data: enabled ? createMockWorkflowToolDetail() : undefined,
isLoading: false,
}))
})
// Rendering Tests (REQUIRED)
@@ -307,19 +312,17 @@ describe('WorkflowToolConfigureButton', () => {
expect(screen.getByText('Please save the workflow first')).toBeInTheDocument()
})
it('should render loading state when published and fetching details', async () => {
it('should render loading state when published and fetching details', () => {
// Arrange
mockFetchWorkflowToolDetailByAppID.mockImplementation(() => new Promise(() => { })) // Never resolves
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true })
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
const loadingElement = document.querySelector('.pt-2')
expect(loadingElement).toBeInTheDocument()
})
const loadingElement = document.querySelector('.pt-2')
expect(loadingElement).toBeInTheDocument()
})
it('should render configure and manage buttons when published', async () => {
@@ -381,76 +384,10 @@ describe('WorkflowToolConfigureButton', () => {
// Act & Assert
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
})
it('should call handlePublish when updating workflow tool', async () => {
// Arrange
const user = userEvent.setup()
const handlePublish = vi.fn().mockResolvedValue(undefined)
mockSaveWorkflowToolProvider.mockResolvedValue({})
const props = createDefaultConfigureButtonProps({ published: true, handlePublish })
// Act
render(<WorkflowToolConfigureButton {...props} />)
await waitFor(() => {
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
})
await user.click(screen.getByText('workflow.common.configure'))
// Fill required fields and save
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
const saveButton = screen.getByText('common.operation.save')
await user.click(saveButton)
// Confirm in modal
await waitFor(() => {
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
})
await user.click(screen.getByText('common.operation.confirm'))
// Assert
await waitFor(() => {
expect(handlePublish).toHaveBeenCalled()
})
})
})
// State Management Tests
describe('State Management', () => {
it('should fetch detail when published and mount', async () => {
// Arrange
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledWith('workflow-app-123')
})
})
it('should refetch detail when detailNeedUpdate changes to true', async () => {
// Arrange
const props = createDefaultConfigureButtonProps({ published: true, detailNeedUpdate: false })
// Act
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
await waitFor(() => {
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(1)
})
// Rerender with detailNeedUpdate true
rerender(<WorkflowToolConfigureButton {...props} detailNeedUpdate={true} />)
// Assert
await waitFor(() => {
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(2)
})
})
// Modal behavior tests
describe('Modal Behavior', () => {
it('should toggle modal visibility', async () => {
// Arrange
const user = userEvent.setup()
@@ -513,85 +450,6 @@ describe('WorkflowToolConfigureButton', () => {
})
})
// Memoization Tests
describe('Memoization - outdated detection', () => {
it('should detect outdated when parameter count differs', async () => {
// Arrange
const detail = createMockWorkflowToolDetail()
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
const props = createDefaultConfigureButtonProps({
published: true,
inputs: [
createMockInputVar({ variable: 'test_var' }),
createMockInputVar({ variable: 'extra_var' }),
],
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert - should show outdated warning
await waitFor(() => {
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
})
})
it('should detect outdated when parameter not found', async () => {
// Arrange
const detail = createMockWorkflowToolDetail()
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
const props = createDefaultConfigureButtonProps({
published: true,
inputs: [createMockInputVar({ variable: 'different_var' })],
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
})
})
it('should detect outdated when required property differs', async () => {
// Arrange
const detail = createMockWorkflowToolDetail()
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
const props = createDefaultConfigureButtonProps({
published: true,
inputs: [createMockInputVar({ variable: 'test_var', required: false })], // Detail has required: true
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
})
})
it('should not show outdated when parameters match', async () => {
// Arrange
const detail = createMockWorkflowToolDetail()
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
const props = createDefaultConfigureButtonProps({
published: true,
inputs: [createMockInputVar({ variable: 'test_var', required: true, type: InputVarType.textInput })],
})
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
})
expect(screen.queryByText('workflow.common.workflowAsToolTip')).not.toBeInTheDocument()
})
})
// User Interactions Tests
describe('User Interactions', () => {
it('should navigate to tools page when manage button clicked', async () => {
@@ -611,174 +469,10 @@ describe('WorkflowToolConfigureButton', () => {
// Assert
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
})
it('should create workflow tool provider on first publish', async () => {
// Arrange
const user = userEvent.setup()
mockCreateWorkflowToolProvider.mockResolvedValue({})
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Open modal
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
// Fill in required name field
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
await user.type(nameInput, 'my_tool')
// Click save
await user.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
})
})
it('should show success toast after creating workflow tool', async () => {
// Arrange
const user = userEvent.setup()
mockCreateWorkflowToolProvider.mockResolvedValue({})
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
await user.type(nameInput, 'my_tool')
await user.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.api.actionSuccess',
})
})
})
it('should show error toast when create fails', async () => {
// Arrange
const user = userEvent.setup()
mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed'))
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
await user.type(nameInput, 'my_tool')
await user.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Create failed',
})
})
})
it('should call onRefreshData after successful create', async () => {
// Arrange
const user = userEvent.setup()
const onRefreshData = vi.fn()
mockCreateWorkflowToolProvider.mockResolvedValue({})
const props = createDefaultConfigureButtonProps({ onRefreshData })
// Act
render(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
await user.type(nameInput, 'my_tool')
await user.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(onRefreshData).toHaveBeenCalled()
})
})
it('should invalidate all workflow tools after successful create', async () => {
// Arrange
const user = userEvent.setup()
mockCreateWorkflowToolProvider.mockResolvedValue({})
const props = createDefaultConfigureButtonProps()
// Act
render(<WorkflowToolConfigureButton {...props} />)
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
await user.click(triggerArea!)
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
await user.type(nameInput, 'my_tool')
await user.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
})
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle API returning undefined', async () => {
// Arrange - API returns undefined (simulating empty response or handled error)
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(undefined)
const props = createDefaultConfigureButtonProps({ published: true })
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert - should not crash and wait for API call
await waitFor(() => {
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
})
// Component should still render without crashing
await waitFor(() => {
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
})
})
it('should handle rapid publish/unpublish state changes', async () => {
// Arrange
const props = createDefaultConfigureButtonProps({ published: false })
@@ -798,35 +492,7 @@ describe('WorkflowToolConfigureButton', () => {
})
// Assert - should not crash
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
})
it('should handle detail with empty parameters', async () => {
// Arrange
const detail = createMockWorkflowToolDetail()
detail.tool.parameters = []
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
const props = createDefaultConfigureButtonProps({ published: true, inputs: [] })
// Act
render(<WorkflowToolConfigureButton {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
})
})
it('should handle detail with undefined output_schema', async () => {
// Arrange
const detail = createMockWorkflowToolDetail()
// @ts-expect-error - testing undefined case
detail.tool.output_schema = undefined
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
const props = createDefaultConfigureButtonProps({ published: true })
// Act & Assert
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
})
it('should handle paragraph type input conversion', async () => {
@@ -1853,7 +1519,10 @@ describe('Integration Tests', () => {
vi.clearAllMocks()
mockPortalOpenState = false
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail())
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
data: enabled ? createMockWorkflowToolDetail() : undefined,
isLoading: false,
}))
})
// Complete workflow: open modal -> fill form -> save

View File

@@ -1,22 +1,16 @@
'use client'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { Emoji } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import { useAppContext } from '@/context/app-context'
import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools'
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import Divider from '../../base/divider'
import { useConfigureButton } from './hooks/use-configure-button'
type Props = {
disabled: boolean
@@ -48,153 +42,29 @@ const WorkflowToolConfigureButton = ({
disabledReason,
}: Props) => {
const { t } = useTranslation()
const router = useRouter()
const [showModal, setShowModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [detail, setDetail] = useState<WorkflowToolProviderResponse>()
const { isCurrentWorkspaceManager } = useAppContext()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
const outdated = useMemo(() => {
if (!detail)
return false
if (detail.tool.parameters.length !== inputs?.length) {
return true
}
else {
for (const item of inputs || []) {
const param = detail.tool.parameters.find(toolParam => toolParam.name === item.variable)
if (!param) {
return true
}
else if (param.required !== item.required) {
return true
}
else {
if (item.type === 'paragraph' && param.type !== 'string')
return true
if (item.type === 'text-input' && param.type !== 'string')
return true
}
}
}
return false
}, [detail, inputs])
const payload = useMemo(() => {
let parameters: WorkflowToolProviderParameter[] = []
let outputParameters: WorkflowToolProviderOutputParameter[] = []
if (!published) {
parameters = (inputs || []).map((item) => {
return {
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}
})
outputParameters = (outputs || []).map((item) => {
return {
name: item.variable,
description: '',
type: item.value_type,
}
})
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => {
return {
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(param => param.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(param => param.name === item.variable)?.form || 'llm',
}
})
outputParameters = (outputs || []).map((item) => {
const found = detail.tool.output_schema?.properties?.[item.variable]
return {
name: item.variable,
description: found ? found.description : '',
type: item.value_type,
}
})
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
name: detail?.name || '',
description: detail?.description || description,
parameters,
outputParameters,
labels: detail?.tool?.labels || [],
privacy_policy: detail?.privacy_policy || '',
...(published
? {
workflow_tool_id: detail?.workflow_tool_id,
}
: {
workflow_app_id: workflowAppId,
}),
}
}, [detail, published, workflowAppId, icon, name, description, inputs])
const getDetail = useCallback(async (workflowAppId: string) => {
setIsLoading(true)
const res = await fetchWorkflowToolDetailByAppID(workflowAppId)
setDetail(res)
setIsLoading(false)
}, [])
useEffect(() => {
if (published)
getDetail(workflowAppId)
}, [getDetail, published, workflowAppId])
useEffect(() => {
if (detailNeedUpdate)
getDetail(workflowAppId)
}, [detailNeedUpdate, getDetail, workflowAppId])
const createHandle = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
await createWorkflowToolProvider(data)
invalidateAllWorkflowTools()
onRefreshData?.()
getDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
try {
await handlePublish()
await saveWorkflowToolProvider(data)
onRefreshData?.()
invalidateAllWorkflowTools()
getDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
const {
showModal,
isLoading,
outdated,
payload,
isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate,
handleUpdate,
navigateToTools,
} = useConfigureButton({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
})
return (
<>
@@ -210,17 +80,17 @@ const WorkflowToolConfigureButton = ({
? (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={() => !disabled && !published && setShowModal(true)}
onClick={() => !disabled && !published && openModal()}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className={cn('system-sm-medium shrink grow basis-0 truncate text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
className={cn('shrink grow basis-0 truncate text-text-secondary system-sm-medium', !disabled && !published && 'group-hover:text-text-accent')}
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
{!published && (
<span className="system-2xs-medium-uppercase shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary">
<span className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary system-2xs-medium-uppercase">
{t('common.configureRequired', { ns: 'workflow' })}
</span>
)}
@@ -233,7 +103,7 @@ const WorkflowToolConfigureButton = ({
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="system-sm-medium shrink grow basis-0 truncate text-text-tertiary"
className="shrink grow basis-0 truncate text-text-tertiary system-sm-medium"
>
{t('common.workflowAsTool', { ns: 'workflow' })}
</div>
@@ -250,7 +120,7 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={() => setShowModal(true)}
onClick={openModal}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
@@ -259,7 +129,7 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={() => router.push('/tools?category=workflow')}
onClick={navigateToTools}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
@@ -280,9 +150,9 @@ const WorkflowToolConfigureButton = ({
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={() => setShowModal(false)}
onCreate={createHandle}
onSave={updateWorkflowToolProvider}
onHide={closeModal}
onCreate={handleCreate}
onSave={handleUpdate}
/>
)}
</>

View File

@@ -0,0 +1,541 @@
import type { WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { isParametersOutdated, useConfigureButton } from '../use-configure-button'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
}))
const mockCreateWorkflowToolProvider = vi.fn()
const mockSaveWorkflowToolProvider = vi.fn()
vi.mock('@/service/tools', () => ({
createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args),
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
}))
const mockInvalidateAllWorkflowTools = vi.fn()
const mockInvalidateWorkflowToolDetailByAppID = vi.fn()
const mockUseWorkflowToolDetailByAppID = vi.fn()
vi.mock('@/service/use-tools', () => ({
useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools,
useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID,
useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (options: { type: string, message: string }) => mockToastNotify(options),
},
}))
const createMockEmoji = () => ({ content: '🔧', background: '#ffffff' })
const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
variable: 'test_var',
label: 'Test Variable',
type: InputVarType.textInput,
required: true,
max_length: 100,
options: [],
...overrides,
} as InputVar)
const createMockVariable = (overrides: Partial<Variable> = {}): Variable => ({
variable: 'output_var',
value_type: 'string',
...overrides,
} as Variable)
const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {}): WorkflowToolProviderResponse => ({
workflow_app_id: 'app-123',
workflow_tool_id: 'tool-456',
label: 'Test Tool',
name: 'test_tool',
icon: createMockEmoji(),
description: 'A test workflow tool',
synced: true,
tool: {
author: 'test-author',
name: 'test_tool',
label: { en_US: 'Test Tool', zh_Hans: '测试工具' },
description: { en_US: 'Test description', zh_Hans: '测试描述' },
labels: ['label1'],
parameters: [
{
name: 'test_var',
label: { en_US: 'Test Variable', zh_Hans: '测试变量' },
human_description: { en_US: 'A test variable', zh_Hans: '测试变量' },
type: 'string',
form: 'llm',
llm_description: 'Test variable description',
required: true,
default: '',
},
],
output_schema: {
type: 'object',
properties: {
output_var: { type: 'string', description: 'Output description' },
},
},
},
privacy_policy: 'https://example.com/privacy',
...overrides,
})
const createDefaultOptions = (overrides = {}) => ({
published: false,
detailNeedUpdate: false,
workflowAppId: 'app-123',
icon: createMockEmoji(),
name: 'Test Workflow',
description: 'Test workflow description',
inputs: [createMockInputVar()],
outputs: [createMockVariable()],
handlePublish: vi.fn().mockResolvedValue(undefined),
onRefreshData: vi.fn(),
...overrides,
})
const createMockRequest = (extra: Record<string, string> = {}): WorkflowToolProviderRequest & Record<string, unknown> => ({
name: 'test_tool',
description: 'desc',
icon: createMockEmoji(),
label: 'Test Tool',
parameters: [{ name: 'test_var', description: '', form: 'llm' }],
labels: [],
privacy_policy: '',
...extra,
})
describe('isParametersOutdated', () => {
it('should return false when detail is undefined', () => {
expect(isParametersOutdated(undefined, [createMockInputVar()])).toBe(false)
})
it('should return true when parameter count differs', () => {
const detail = createMockDetail()
const inputs = [
createMockInputVar({ variable: 'test_var' }),
createMockInputVar({ variable: 'extra_var' }),
]
expect(isParametersOutdated(detail, inputs)).toBe(true)
})
it('should return true when parameter is not found in detail', () => {
const detail = createMockDetail()
const inputs = [createMockInputVar({ variable: 'unknown_var' })]
expect(isParametersOutdated(detail, inputs)).toBe(true)
})
it('should return true when required property differs', () => {
const detail = createMockDetail()
const inputs = [createMockInputVar({ variable: 'test_var', required: false })]
expect(isParametersOutdated(detail, inputs)).toBe(true)
})
it('should return true when paragraph type does not match string', () => {
const detail = createMockDetail()
detail.tool.parameters[0].type = 'number'
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })]
expect(isParametersOutdated(detail, inputs)).toBe(true)
})
it('should return true when text-input type does not match string', () => {
const detail = createMockDetail()
detail.tool.parameters[0].type = 'number'
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.textInput })]
expect(isParametersOutdated(detail, inputs)).toBe(true)
})
it('should return false when paragraph type matches string', () => {
const detail = createMockDetail()
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })]
expect(isParametersOutdated(detail, inputs)).toBe(false)
})
it('should return false when text-input type matches string', () => {
const detail = createMockDetail()
const inputs = [createMockInputVar({ variable: 'test_var', type: InputVarType.textInput })]
expect(isParametersOutdated(detail, inputs)).toBe(false)
})
it('should return false when all parameters match', () => {
const detail = createMockDetail()
const inputs = [createMockInputVar({ variable: 'test_var', required: true })]
expect(isParametersOutdated(detail, inputs)).toBe(false)
})
it('should handle undefined inputs with empty detail parameters', () => {
const detail = createMockDetail()
detail.tool.parameters = []
expect(isParametersOutdated(detail, undefined)).toBe(false)
})
it('should return true when inputs undefined but detail has parameters', () => {
const detail = createMockDetail()
expect(isParametersOutdated(detail, undefined)).toBe(true)
})
it('should handle empty inputs and empty detail parameters', () => {
const detail = createMockDetail()
detail.tool.parameters = []
expect(isParametersOutdated(detail, [])).toBe(false)
})
})
describe('useConfigureButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({
data: enabled ? createMockDetail() : undefined,
isLoading: false,
}))
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Initialization', () => {
it('should return showModal as false by default', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
expect(result.current.showModal).toBe(false)
})
it('should forward isCurrentWorkspaceManager from context', () => {
mockIsCurrentWorkspaceManager.mockReturnValue(false)
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
expect(result.current.isCurrentWorkspaceManager).toBe(false)
})
it('should forward isLoading from query hook', () => {
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: true })
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(result.current.isLoading).toBe(true)
})
it('should call query hook with enabled=true when published', () => {
renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', true)
})
it('should call query hook with enabled=false when not published', () => {
renderHook(() => useConfigureButton(createDefaultOptions({ published: false })))
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false)
})
})
// Computed values
describe('Computed - outdated', () => {
it('should be false when not published (no detail)', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
expect(result.current.outdated).toBe(false)
})
it('should be true when parameters differ', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
inputs: [
createMockInputVar({ variable: 'test_var' }),
createMockInputVar({ variable: 'extra_var' }),
],
})))
expect(result.current.outdated).toBe(true)
})
it('should be false when parameters match', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
inputs: [createMockInputVar({ variable: 'test_var', required: true })],
})))
expect(result.current.outdated).toBe(false)
})
})
describe('Computed - payload', () => {
it('should use prop values when not published', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
expect(result.current.payload).toMatchObject({
icon: createMockEmoji(),
label: 'Test Workflow',
name: '',
description: 'Test workflow description',
workflow_app_id: 'app-123',
})
expect(result.current.payload.parameters).toHaveLength(1)
expect(result.current.payload.parameters[0]).toMatchObject({
name: 'test_var',
form: 'llm',
description: '',
})
})
it('should use detail values when published with detail', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(result.current.payload).toMatchObject({
icon: createMockEmoji(),
label: 'Test Tool',
name: 'test_tool',
description: 'A test workflow tool',
workflow_tool_id: 'tool-456',
privacy_policy: 'https://example.com/privacy',
labels: ['label1'],
})
expect(result.current.payload.parameters[0]).toMatchObject({
name: 'test_var',
description: 'Test variable description',
form: 'llm',
})
})
it('should return empty parameters when published without detail', () => {
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false })
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(result.current.payload.parameters).toHaveLength(0)
expect(result.current.payload.outputParameters).toHaveLength(0)
})
it('should build output parameters from detail output_schema', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(result.current.payload.outputParameters).toHaveLength(1)
expect(result.current.payload.outputParameters[0]).toMatchObject({
name: 'output_var',
description: 'Output description',
})
})
it('should handle undefined output_schema in detail', () => {
const detail = createMockDetail()
// @ts-expect-error - testing undefined case
detail.tool.output_schema = undefined
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false })
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(result.current.payload.outputParameters[0]).toMatchObject({
name: 'output_var',
description: '',
})
})
it('should convert paragraph type to string in existing parameters', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })],
})))
expect(result.current.payload.parameters[0].type).toBe('string')
})
})
// Modal controls
describe('Modal Controls', () => {
it('should open modal via openModal', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
act(() => {
result.current.openModal()
})
expect(result.current.showModal).toBe(true)
})
it('should close modal via closeModal', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
act(() => {
result.current.openModal()
})
act(() => {
result.current.closeModal()
})
expect(result.current.showModal).toBe(false)
})
it('should navigate to tools page', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
act(() => {
result.current.navigateToTools()
})
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
})
})
// Mutation handlers
describe('handleCreate', () => {
it('should create provider, invalidate caches, refresh, and close modal', async () => {
mockCreateWorkflowToolProvider.mockResolvedValue({})
const onRefreshData = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData })))
act(() => {
result.current.openModal()
})
await act(async () => {
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
})
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
expect(onRefreshData).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
expect(result.current.showModal).toBe(false)
})
it('should show error toast on failure', async () => {
mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed'))
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
await act(async () => {
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
})
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Create failed' })
})
})
describe('handleUpdate', () => {
it('should publish, save, invalidate caches, and close modal', async () => {
mockSaveWorkflowToolProvider.mockResolvedValue({})
const handlePublish = vi.fn().mockResolvedValue(undefined)
const onRefreshData = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
handlePublish,
onRefreshData,
})))
act(() => {
result.current.openModal()
})
await act(async () => {
await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
})
expect(handlePublish).toHaveBeenCalled()
expect(mockSaveWorkflowToolProvider).toHaveBeenCalled()
expect(onRefreshData).toHaveBeenCalled()
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
expect(result.current.showModal).toBe(false)
})
it('should show error toast when publish fails', async () => {
const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed'))
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
handlePublish,
})))
await act(async () => {
await result.current.handleUpdate(createMockRequest() as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
})
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Publish failed' })
})
it('should show error toast when save fails', async () => {
mockSaveWorkflowToolProvider.mockRejectedValue(new Error('Save failed'))
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
await act(async () => {
await result.current.handleUpdate(createMockRequest() as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
})
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Save failed' })
})
})
// Effects
describe('Effects', () => {
it('should invalidate detail when detailNeedUpdate becomes true', () => {
const options = createDefaultOptions({ published: true, detailNeedUpdate: false })
const { rerender } = renderHook(
(props: ReturnType<typeof createDefaultOptions>) => useConfigureButton(props),
{ initialProps: options },
)
rerender({ ...options, detailNeedUpdate: true })
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
})
it('should not invalidate when detailNeedUpdate stays false', () => {
const options = createDefaultOptions({ published: true, detailNeedUpdate: false })
const { rerender } = renderHook(
(props: ReturnType<typeof createDefaultOptions>) => useConfigureButton(props),
{ initialProps: options },
)
rerender({ ...options })
expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined detail from query gracefully', () => {
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: undefined, isLoading: false })
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ published: true })))
expect(result.current.outdated).toBe(false)
expect(result.current.payload.parameters).toHaveLength(0)
})
it('should handle detail with empty parameters', () => {
const detail = createMockDetail()
detail.tool.parameters = []
mockUseWorkflowToolDetailByAppID.mockReturnValue({ data: detail, isLoading: false })
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
inputs: [],
})))
expect(result.current.outdated).toBe(false)
})
it('should handle undefined inputs and outputs', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
inputs: undefined,
outputs: undefined,
})))
expect(result.current.payload.parameters).toHaveLength(0)
expect(result.current.payload.outputParameters).toHaveLength(0)
})
it('should handle missing onRefreshData callback in create', async () => {
mockCreateWorkflowToolProvider.mockResolvedValue({})
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
onRefreshData: undefined,
})))
// Should not throw
await act(async () => {
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
})
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,235 @@
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools'
import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools'
// region Pure helpers
/**
* Check if workflow tool parameters are outdated compared to current inputs.
* Uses flat early-return style to reduce cyclomatic complexity.
*/
export function isParametersOutdated(
detail: WorkflowToolProviderResponse | undefined,
inputs: InputVar[] | undefined,
): boolean {
if (!detail)
return false
if (detail.tool.parameters.length !== (inputs?.length ?? 0))
return true
for (const item of inputs || []) {
const param = detail.tool.parameters.find(p => p.name === item.variable)
if (!param)
return true
if (param.required !== item.required)
return true
const needsStringType = item.type === 'paragraph' || item.type === 'text-input'
if (needsStringType && param.type !== 'string')
return true
}
return false
}
function buildNewParameters(inputs?: InputVar[]): WorkflowToolProviderParameter[] {
return (inputs || []).map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
function buildExistingParameters(
inputs: InputVar[] | undefined,
detail: WorkflowToolProviderResponse,
): WorkflowToolProviderParameter[] {
return (inputs || []).map((item) => {
const matched = detail.tool.parameters.find(p => p.name === item.variable)
return {
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: matched?.llm_description || '',
form: matched?.form || 'llm',
}
})
}
function buildNewOutputParameters(outputs?: Variable[]): WorkflowToolProviderOutputParameter[] {
return (outputs || []).map(item => ({
name: item.variable,
description: '',
type: item.value_type,
}))
}
function buildExistingOutputParameters(
outputs: Variable[] | undefined,
detail: WorkflowToolProviderResponse,
): WorkflowToolProviderOutputParameter[] {
return (outputs || []).map((item) => {
const found = detail.tool.output_schema?.properties?.[item.variable]
return {
name: item.variable,
description: found ? found.description : '',
type: item.value_type,
}
})
}
// endregion
type UseConfigureButtonOptions = {
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
}
export function useConfigureButton(options: UseConfigureButtonOptions) {
const {
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
} = options
const { t } = useTranslation()
const router = useRouter()
const { isCurrentWorkspaceManager } = useAppContext()
const [showModal, setShowModal] = useState(false)
// Data fetching via React Query
const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, published)
// Invalidation functions (store in ref for stable effect dependency)
const invalidateDetail = useInvalidateWorkflowToolDetailByAppID()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
const invalidateDetailRef = useRef(invalidateDetail)
invalidateDetailRef.current = invalidateDetail
// Refetch when detailNeedUpdate becomes true
useEffect(() => {
if (detailNeedUpdate)
invalidateDetailRef.current(workflowAppId)
}, [detailNeedUpdate, workflowAppId])
// Computed values
const outdated = useMemo(
() => isParametersOutdated(detail, inputs),
[detail, inputs],
)
const payload = useMemo(() => {
const hasPublishedDetail = published && detail?.tool
const parameters = !published
? buildNewParameters(inputs)
: hasPublishedDetail
? buildExistingParameters(inputs, detail)
: []
const outputParameters = !published
? buildNewOutputParameters(outputs)
: hasPublishedDetail
? buildExistingOutputParameters(outputs, detail)
: []
return {
icon: detail?.icon || icon,
label: detail?.label || name,
name: detail?.name || '',
description: detail?.description || description,
parameters,
outputParameters,
labels: detail?.tool?.labels || [],
privacy_policy: detail?.privacy_policy || '',
...(published
? { workflow_tool_id: detail?.workflow_tool_id }
: { workflow_app_id: workflowAppId }),
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
// Modal controls (stable callbacks)
const openModal = useCallback(() => setShowModal(true), [])
const closeModal = useCallback(() => setShowModal(false), [])
const navigateToTools = useCallback(
() => router.push('/tools?category=workflow'),
[router],
)
// Mutation handlers (not memoized — only used in conditionally-rendered modal)
const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
await createWorkflowToolProvider(data)
invalidateAllWorkflowTools()
onRefreshData?.()
invalidateDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
const handleUpdate = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
try {
await handlePublish()
await saveWorkflowToolProvider(data)
onRefreshData?.()
invalidateAllWorkflowTools()
invalidateDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
return {
showModal,
isLoading,
outdated,
payload,
isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate,
handleUpdate,
navigateToTools,
}
}

View File

@@ -784,11 +784,6 @@
"count": 1
}
},
"app/components/app/configuration/dataset-config/card-item/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/app/configuration/dataset-config/card-item/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@@ -1231,11 +1226,6 @@
"count": 1
}
},
"app/components/apps/app-card.spec.tsx": {
"ts/no-explicit-any": {
"count": 22
}
},
"app/components/apps/app-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1260,21 +1250,11 @@
"count": 1
}
},
"app/components/apps/list.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/apps/list.tsx": {
"unused-imports/no-unused-vars": {
"count": 1
}
},
"app/components/apps/new-app-card.spec.tsx": {
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/apps/new-app-card.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -3042,11 +3022,6 @@
"count": 1
}
},
"app/components/custom/custom-web-app-brand/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7
}
},
"app/components/custom/custom-web-app-brand/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 12
@@ -4073,14 +4048,6 @@
"count": 9
}
},
"app/components/develop/doc.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/develop/md.tsx": {
"ts/no-empty-object-type": {
"count": 1
@@ -4735,14 +4702,6 @@
"count": 1
}
},
"app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 5
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/plugins/install-plugin/install-bundle/steps/install.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
@@ -5766,11 +5725,6 @@
"count": 4
}
},
"app/components/tools/workflow-tool/configure-button.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/tools/workflow-tool/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 7
@@ -5807,11 +5761,6 @@
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow-app/hooks/use-DSL.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@@ -3,6 +3,7 @@ import type {
Collection,
MCPServerDetail,
Tool,
WorkflowToolProviderResponse,
} from '@/app/components/tools/types'
import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types'
import type { AppIconType } from '@/types/app'
@@ -402,3 +403,22 @@ export const useUpdateTriggerStatus = () => {
},
})
}
const workflowToolDetailByAppIDKey = (appId: string) => [NAME_SPACE, 'workflowToolDetailByAppID', appId]
export const useWorkflowToolDetailByAppID = (appId: string, enabled = true) => {
return useQuery<WorkflowToolProviderResponse>({
queryKey: workflowToolDetailByAppIDKey(appId),
queryFn: () => get<WorkflowToolProviderResponse>(`/workspaces/current/tool-provider/workflow/get?workflow_app_id=${appId}`),
enabled: enabled && !!appId,
})
}
export const useInvalidateWorkflowToolDetailByAppID = () => {
const queryClient = useQueryClient()
return (appId: string) => {
queryClient.invalidateQueries({
queryKey: workflowToolDetailByAppIDKey(appId),
})
}
}