Compare commits

..

4 Commits

144 changed files with 1399 additions and 12659 deletions

View File

@ -1,6 +1,6 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
---
# How To Write A Component
@ -12,7 +12,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; inline cheap derived values when that is clearer.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Ownership
@ -20,8 +19,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
@ -32,9 +29,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
## Queries And Mutations
@ -51,13 +48,12 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Component Boundaries
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
## You Might Not Need An Effect

View File

@ -9,7 +9,6 @@ on:
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- "4-27-app-deploy"
tags:
- "*"

View File

@ -3,6 +3,7 @@ from urllib import parse
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy import func, select
import services
from configs import dify_config
@ -21,15 +22,15 @@ from controllers.console.auth.error import (
from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
is_allow_transfer_owner,
setup_required,
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.member_fields import AccountWithRole, AccountWithRoleList
from libs.helper import extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountRole
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
@ -78,6 +79,54 @@ def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
def _normalize_invitee_emails(emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email)
if not account:
new_member_count += 1
continue
exists = db.session.scalar(
select(TenantAccountJoin.id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not exists:
new_member_count += 1
return new_member_count
def _count_current_members(tenant_id: str) -> int:
return (
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
)
def _check_member_invite_limits(tenant_id: str, new_member_count: int) -> None:
if new_member_count <= 0:
return
features = FeatureService.get_features(tenant_id=tenant_id)
if dify_config.ENTERPRISE_ENABLED:
workspace_members = features.workspace_members
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
return
if dify_config.BILLING_ENABLED and features.billing.enabled is True:
members = features.members
current_member_count = _count_current_members(tenant_id)
if 0 < members.limit < current_member_count + new_member_count:
raise WorkspaceMembersLimitExceeded()
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@ -104,12 +153,11 @@ class MemberInviteEmailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("members")
def post(self):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = args.emails
invitee_emails = _normalize_invitee_emails(args.emails)
invitee_role = args.role
interface_language = args.language
if not TenantAccountRole.is_non_owner_role(invitee_role):
@ -129,37 +177,36 @@ class MemberInviteEmailApi(Resource):
invitation_results = []
console_web_url = dify_config.CONSOLE_WEB_URL
workspace_members = FeatureService.get_features(tenant_id=inviter.current_tenant.id).workspace_members
tenant_id = inviter.current_tenant.id
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
new_member_count = _count_new_member_invites(tenant_id, invitee_emails)
_check_member_invite_limits(tenant_id, new_member_count)
if not workspace_members.is_available(len(invitee_emails)):
raise WorkspaceMembersLimitExceeded()
for invitee_email in invitee_emails:
normalized_invitee_email = invitee_email.lower()
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
)
encoded_invitee_email = parse.quote(normalized_invitee_email)
invitation_results.append(
{
"status": "success",
"email": normalized_invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
)
except AccountAlreadyInTenantError:
invitation_results.append(
{"status": "success", "email": normalized_invitee_email, "url": f"{console_web_url}/signin"}
)
except Exception as e:
invitation_results.append({"status": "failed", "email": normalized_invitee_email, "message": str(e)})
for invitee_email in invitee_emails:
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
{
"status": "success",
"email": invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
)
except AccountAlreadyInTenantError:
invitation_results.append(
{"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"}
)
except Exception as e:
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
return {
"result": "success",

View File

@ -16,7 +16,6 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .app import dsl as _app_dsl
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@ -27,7 +26,6 @@ __all__ = [
"_app_dsl",
"_mail",
"_plugin",
"_runtime_credentials",
"_workspace",
"api",
"bp",

View File

@ -1,129 +0,0 @@
"""Inner API endpoints for runtime credential resolution.
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
returns decrypted model credentials for in-memory runtime use only.
"""
import json
import logging
from json import JSONDecodeError
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.helper import encrypter
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from extensions.ext_database import db
from models.provider import ProviderCredential
logger = logging.getLogger(__name__)
class InnerRuntimeModelCredentialResolveItem(BaseModel):
credential_id: str = Field(description="Provider credential id")
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
vendor: str | None = Field(default=None, description="Model vendor, for example openai")
plugin_unique_identifier: str | None = Field(default=None, description="Runtime plugin identifier")
class InnerRuntimeModelCredentialsResolvePayload(BaseModel):
tenant_id: str = Field(description="Workspace id")
credentials: list[InnerRuntimeModelCredentialResolveItem] = Field(default_factory=list)
register_schema_model(inner_api_ns, InnerRuntimeModelCredentialsResolvePayload)
@inner_api_ns.route("/enterprise/runtime/model-credentials:resolve")
class EnterpriseRuntimeModelCredentialsResolve(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_runtime_model_credentials_resolve",
responses={
200: "Credentials resolved",
400: "Invalid request or credential config",
404: "Provider or credential not found",
},
)
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeModelCredentialsResolvePayload.__name__])
def post(self):
args = InnerRuntimeModelCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
if not args.credentials:
return {"model_credentials": []}, 200
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
provider_configurations = provider_manager.get_configurations(args.tenant_id)
resolved: list[dict[str, Any]] = []
for item in args.credentials:
provider_configuration = provider_configurations.get(item.provider)
if provider_configuration is None:
return {"message": f"provider '{item.provider}' not found"}, 404
provider_schema = provider_configuration.provider.provider_credential_schema
secret_variables = provider_configuration.extract_secret_variables(
provider_schema.credential_form_schemas if provider_schema else []
)
with Session(db.engine) as session:
stmt = select(ProviderCredential).where(
ProviderCredential.id == item.credential_id,
ProviderCredential.tenant_id == args.tenant_id,
ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()),
)
credential = session.execute(stmt).scalar_one_or_none()
if credential is None or not credential.encrypted_config:
return {"message": f"credential '{item.credential_id}' not found"}, 404
try:
values = json.loads(credential.encrypted_config)
except JSONDecodeError:
return {"message": f"credential '{item.credential_id}' has invalid config"}, 400
if not isinstance(values, dict):
return {"message": f"credential '{item.credential_id}' has invalid config"}, 400
for key in secret_variables:
value = values.get(key)
if value is None:
continue
try:
values[key] = encrypter.decrypt_token(tenant_id=args.tenant_id, token=value)
except Exception as exc:
logger.warning(
"failed to resolve runtime model credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": args.tenant_id,
"error": type(exc).__name__,
},
)
return {"message": f"credential '{item.credential_id}' decrypt failed"}, 400
resolved.append(
{
"credential_id": item.credential_id,
"provider": item.provider,
"vendor": item.vendor or _vendor_from_provider(item.provider),
"plugin_unique_identifier": item.plugin_unique_identifier,
"values": values,
}
)
return {"model_credentials": resolved}, 200
def _vendor_from_provider(provider: str) -> str:
provider = provider.strip("/")
if not provider:
return ""
return provider.rsplit("/", 1)[-1]

View File

@ -14225,7 +14225,6 @@ Default configuration for form inputs.
| ---- | ---- | ----------- | -------- |
| app_dsl_version | string | | Yes |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean | | Yes |
| enable_collaboration_mode | boolean | | Yes |
| enable_creators_platform | boolean | | Yes |

View File

@ -1325,7 +1325,6 @@ Returns Server-Sent Events stream.
| ---- | ---- | ----------- | -------- |
| app_dsl_version | string | | Yes |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean | | Yes |
| enable_collaboration_mode | boolean | | Yes |
| enable_creators_platform | boolean | | Yes |

View File

@ -161,7 +161,6 @@ class PluginManagerModel(FeatureResponseModel):
class SystemFeatureModel(FeatureResponseModel):
app_dsl_version: str = ""
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
@ -236,7 +235,6 @@ class FeatureService:
cls._fulfill_system_params_from_env(system_features)
if dify_config.ENTERPRISE_ENABLED:
system_features.enable_app_deploy = True
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False

View File

@ -291,7 +291,6 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
@ -378,7 +377,6 @@ class TestFeatureService:
# Ensure that data required for frontend rendering remains accessible.
# Branding should match the mock data
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.branding.application_title == "Test Enterprise"
assert result.branding.login_page_logo == "https://example.com/logo.png"
@ -426,7 +424,6 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify basic configuration
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True
@ -628,7 +625,6 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features are disabled
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True

View File

@ -1,3 +1,4 @@
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import patch
@ -18,7 +19,7 @@ def app():
def _build_feature_flags():
placeholder_quota = SimpleNamespace(limit=0, size=0)
workspace_members = SimpleNamespace(is_available=lambda count: True)
workspace_members = SimpleNamespace(enabled=False, is_available=lambda count: True)
return SimpleNamespace(
billing=SimpleNamespace(enabled=False),
workspace_members=workspace_members,
@ -31,6 +32,11 @@ def _build_feature_flags():
class TestMemberInviteEmailApi:
@pytest.fixture(autouse=True)
def _mock_member_invite_lock(self):
with patch("controllers.console.workspace.members.redis_client.lock", return_value=nullcontext()):
yield
@patch("controllers.console.workspace.members.FeatureService.get_features")
@patch("controllers.console.workspace.members.RegisterService.invite_new_member")
@patch("controllers.console.workspace.members.current_account_with_tenant")
@ -52,7 +58,12 @@ class TestMemberInviteEmailApi:
inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"):
with (
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
@ -70,7 +81,7 @@ class TestMemberInviteEmailApi:
assert mock_invite_member.call_count == 1
call_args = mock_invite_member.call_args
assert call_args.kwargs["tenant"] == tenant
assert call_args.kwargs["email"] == "User@Example.com"
assert call_args.kwargs["email"] == "user@example.com"
assert call_args.kwargs["language"] == "en-US"
assert call_args.kwargs["role"] == TenantAccountRole.EDITOR
assert call_args.kwargs["inviter"] == inviter

View File

@ -1,3 +1,4 @@
from contextlib import nullcontext
from unittest.mock import MagicMock, patch
import pytest
@ -75,6 +76,11 @@ class TestMemberListApi:
class TestMemberInviteEmailApi:
@pytest.fixture(autouse=True)
def _mock_member_invite_lock(self):
with patch("controllers.console.workspace.members.redis_client.lock", return_value=nullcontext()):
yield
def test_invite_success(self, app: Flask):
api = MemberInviteEmailApi()
method = unwrap(api.post)
@ -82,6 +88,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
features.workspace_members.is_available.return_value = True
payload = {
@ -94,8 +102,11 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
result, status = method(api)
@ -109,6 +120,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = True
features.workspace_members.is_available.return_value = False
payload = {
@ -120,6 +133,38 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
with pytest.raises(WorkspaceMembersLimitExceeded):
method(api)
def test_invite_billing_limit_exceeded(self, app: Flask):
api = MemberInviteEmailApi()
method = unwrap(api.post)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = True
features.members.size = 9
features.members.limit = 10
features.workspace_members.enabled = False
payload = {
"emails": ["a@test.com", "b@test.com"],
"role": "normal",
}
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=2),
patch("controllers.console.workspace.members._count_current_members", return_value=9),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", True),
):
with pytest.raises(WorkspaceMembersLimitExceeded):
method(api)
@ -131,6 +176,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
features.workspace_members.is_available.return_value = True
payload = {
@ -142,11 +189,14 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=0),
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member",
side_effect=AccountAlreadyInTenantError(),
),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
result, status = method(api)
@ -174,6 +224,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
features.workspace_members.is_available.return_value = True
payload = {
@ -185,11 +237,14 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member",
side_effect=Exception("boom"),
),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
result, _ = method(api)

View File

@ -1,105 +0,0 @@
"""Unit tests for runtime credential inner API."""
import inspect
from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.inner_api.runtime_credentials import (
EnterpriseRuntimeModelCredentialsResolve,
InnerRuntimeModelCredentialsResolvePayload,
)
def test_runtime_model_credentials_payload_accepts_items():
payload = InnerRuntimeModelCredentialsResolvePayload.model_validate(
{
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"vendor": "openai",
}
],
}
)
assert payload.tenant_id == "tenant-1"
assert payload.credentials[0].provider == "langgenius/openai/openai"
@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_returns_decrypted_values(
mock_provider_manager_factory,
mock_session_cls,
mock_db,
mock_decrypt_token,
app: Flask,
):
provider_configuration = MagicMock()
provider_configuration.provider.provider_credential_schema.credential_form_schemas = []
provider_configuration.extract_secret_variables.return_value = ["openai_api_key"]
provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"]
provider_configurations = MagicMock()
provider_configurations.get.return_value = provider_configuration
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
credential = MagicMock()
credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}'
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = credential
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
mock_decrypt_token.return_value = "sk-test"
handler = EnterpriseRuntimeModelCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"vendor": "openai",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["model_credentials"][0]["values"]["openai_api_key"] == "sk-test"
assert body["model_credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1"
mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask):
provider_configurations = MagicMock()
provider_configurations.get.return_value = None
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
handler = EnterpriseRuntimeModelCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "credential-1", "provider": "missing"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "provider" in body["message"]

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@ export type ClientOptions = {
export type SystemFeatureModel = {
app_dsl_version: string
branding: BrandingModel
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
enable_creators_platform: boolean

View File

@ -89,7 +89,6 @@ export const zWebAppAuthModel = z.object({
export const zSystemFeatureModel = z.object({
app_dsl_version: z.string().default(''),
branding: zBrandingModel,
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
enable_creators_platform: z.boolean().default(false),

View File

@ -220,7 +220,6 @@ export type SuggestedQuestionsResponse = {
export type SystemFeatureModel = {
app_dsl_version: string
branding: BrandingModel
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
enable_creators_platform: boolean

View File

@ -366,7 +366,6 @@ export const zWebAppAuthModel = z.object({
export const zSystemFeatureModel = z.object({
app_dsl_version: z.string().default(''),
branding: zBrandingModel,
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
enable_creators_platform: z.boolean().default(false),

View File

@ -4,66 +4,6 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zAccessSubjectServiceListAccessSubjectsQuery,
zAccessSubjectServiceListAccessSubjectsResponse,
zAppDeployAccessServiceCreateDeveloperApiKeyBody,
zAppDeployAccessServiceCreateDeveloperApiKeyPath,
zAppDeployAccessServiceCreateDeveloperApiKeyResponse,
zAppDeployAccessServiceDeleteDeveloperApiKeyPath,
zAppDeployAccessServiceDeleteDeveloperApiKeyResponse,
zAppDeployAccessServiceGetAppInstanceAccessPath,
zAppDeployAccessServiceGetAppInstanceAccessResponse,
zAppDeployAccessServiceRevealDeveloperApiKeyBody,
zAppDeployAccessServiceRevealDeveloperApiKeyPath,
zAppDeployAccessServiceRevealDeveloperApiKeyResponse,
zAppDeployAccessServiceUpdateAccessChannelsBody,
zAppDeployAccessServiceUpdateAccessChannelsPath,
zAppDeployAccessServiceUpdateAccessChannelsResponse,
zAppDeployAccessServiceUpdateDeveloperApiBody,
zAppDeployAccessServiceUpdateDeveloperApiPath,
zAppDeployAccessServiceUpdateDeveloperApiResponse,
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyBody,
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyPath,
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyResponse,
zAppDeploymentServiceCancelDeploymentBody,
zAppDeploymentServiceCancelDeploymentPath,
zAppDeploymentServiceCancelDeploymentResponse,
zAppDeploymentServiceCreateDeploymentBody,
zAppDeploymentServiceCreateDeploymentPath,
zAppDeploymentServiceCreateDeploymentResponse,
zAppDeploymentServiceGetDeploymentPlanPath,
zAppDeploymentServiceGetDeploymentPlanResponse,
zAppDeploymentServiceListEnvironmentDeploymentsPath,
zAppDeploymentServiceListEnvironmentDeploymentsResponse,
zAppDeploymentServiceUndeployRuntimeInstanceBody,
zAppDeploymentServiceUndeployRuntimeInstancePath,
zAppDeploymentServiceUndeployRuntimeInstanceResponse,
zAppInstanceServiceCreateAppInstanceBody,
zAppInstanceServiceCreateAppInstanceResponse,
zAppInstanceServiceDeleteAppInstancePath,
zAppInstanceServiceDeleteAppInstanceResponse,
zAppInstanceServiceGetAppInstanceOverviewPath,
zAppInstanceServiceGetAppInstanceOverviewResponse,
zAppInstanceServiceGetAppInstancePath,
zAppInstanceServiceGetAppInstanceResponse,
zAppInstanceServiceGetAppInstanceSettingsPath,
zAppInstanceServiceGetAppInstanceSettingsResponse,
zAppInstanceServiceImportAppInstanceBody,
zAppInstanceServiceImportAppInstanceResponse,
zAppInstanceServiceListAppInstancesQuery,
zAppInstanceServiceListAppInstancesResponse,
zAppInstanceServiceUpdateAppInstanceBody,
zAppInstanceServiceUpdateAppInstancePath,
zAppInstanceServiceUpdateAppInstanceResponse,
zAppReleaseServiceCreateReleaseBody,
zAppReleaseServiceCreateReleasePath,
zAppReleaseServiceCreateReleaseResponse,
zAppReleaseServiceListReleasesPath,
zAppReleaseServiceListReleasesQuery,
zAppReleaseServiceListReleasesResponse,
zAppReleaseServicePreviewReleaseBody,
zAppReleaseServicePreviewReleasePath,
zAppReleaseServicePreviewReleaseResponse,
zConsoleSsoOAuth2LoginResponse,
zConsoleSsoOidcLoginResponse,
zConsoleSsoSamlLoginResponse,
@ -81,369 +21,6 @@ import {
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
} from './zod.gen'
export const listAccessSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessSubjectService_ListAccessSubjects',
path: '/enterprise/access-subjects',
tags: ['AccessSubjectService'],
})
.input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() }))
.output(zAccessSubjectServiceListAccessSubjectsResponse)
export const accessSubjectService = {
listAccessSubjects,
}
export const listAppInstances = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_ListAppInstances',
path: '/enterprise/app-instances',
tags: ['AppInstanceService'],
})
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
.output(zAppInstanceServiceListAppInstancesResponse)
export const createAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppInstanceService_CreateAppInstance',
path: '/enterprise/app-instances',
tags: ['AppInstanceService'],
})
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
.output(zAppInstanceServiceCreateAppInstanceResponse)
export const importAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppInstanceService_ImportAppInstance',
path: '/enterprise/app-instances/import',
tags: ['AppInstanceService'],
})
.input(z.object({ body: zAppInstanceServiceImportAppInstanceBody }))
.output(zAppInstanceServiceImportAppInstanceResponse)
export const deleteAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AppInstanceService_DeleteAppInstance',
path: '/enterprise/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath }))
.output(zAppInstanceServiceDeleteAppInstanceResponse)
export const getAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstance',
path: '/enterprise/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
.output(zAppInstanceServiceGetAppInstanceResponse)
export const updateAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppInstanceService_UpdateAppInstance',
path: '/enterprise/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(
z.object({
body: zAppInstanceServiceUpdateAppInstanceBody,
params: zAppInstanceServiceUpdateAppInstancePath,
}),
)
.output(zAppInstanceServiceUpdateAppInstanceResponse)
export const getAppInstanceOverview = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstanceOverview',
path: '/enterprise/app-instances/{appInstanceId}/overview',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath }))
.output(zAppInstanceServiceGetAppInstanceOverviewResponse)
export const getAppInstanceSettings = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstanceSettings',
path: '/enterprise/app-instances/{appInstanceId}/settings',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstanceSettingsPath }))
.output(zAppInstanceServiceGetAppInstanceSettingsResponse)
export const appInstanceService = {
listAppInstances,
createAppInstance,
importAppInstance,
deleteAppInstance,
getAppInstance,
updateAppInstance,
getAppInstanceOverview,
getAppInstanceSettings,
}
export const getAppInstanceAccess = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppDeployAccessService_GetAppInstanceAccess',
path: '/enterprise/app-instances/{appInstanceId}/access',
tags: ['AppDeployAccessService'],
})
.input(z.object({ params: zAppDeployAccessServiceGetAppInstanceAccessPath }))
.output(zAppDeployAccessServiceGetAppInstanceAccessResponse)
export const updateAccessChannels = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppDeployAccessService_UpdateAccessChannels',
path: '/enterprise/app-instances/{appInstanceId}/access-channels',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceUpdateAccessChannelsBody,
params: zAppDeployAccessServiceUpdateAccessChannelsPath,
}),
)
.output(zAppDeployAccessServiceUpdateAccessChannelsResponse)
export const updateDeveloperApi = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppDeployAccessService_UpdateDeveloperApi',
path: '/enterprise/app-instances/{appInstanceId}/developer-api',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceUpdateDeveloperApiBody,
params: zAppDeployAccessServiceUpdateDeveloperApiPath,
}),
)
.output(zAppDeployAccessServiceUpdateDeveloperApiResponse)
export const updateEnvironmentAccessPolicy = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'AppDeployAccessService_UpdateEnvironmentAccessPolicy',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceUpdateEnvironmentAccessPolicyBody,
params: zAppDeployAccessServiceUpdateEnvironmentAccessPolicyPath,
}),
)
.output(zAppDeployAccessServiceUpdateEnvironmentAccessPolicyResponse)
export const createDeveloperApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeployAccessService_CreateDeveloperApiKey',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceCreateDeveloperApiKeyBody,
params: zAppDeployAccessServiceCreateDeveloperApiKeyPath,
}),
)
.output(zAppDeployAccessServiceCreateDeveloperApiKeyResponse)
export const deleteDeveloperApiKey = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AppDeployAccessService_DeleteDeveloperApiKey',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys/{apiKeyId}',
tags: ['AppDeployAccessService'],
})
.input(z.object({ params: zAppDeployAccessServiceDeleteDeveloperApiKeyPath }))
.output(zAppDeployAccessServiceDeleteDeveloperApiKeyResponse)
export const revealDeveloperApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeployAccessService_RevealDeveloperApiKey',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys/{apiKeyId}/reveal',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceRevealDeveloperApiKeyBody,
params: zAppDeployAccessServiceRevealDeveloperApiKeyPath,
}),
)
.output(zAppDeployAccessServiceRevealDeveloperApiKeyResponse)
export const appDeployAccessService = {
getAppInstanceAccess,
updateAccessChannels,
updateDeveloperApi,
updateEnvironmentAccessPolicy,
createDeveloperApiKey,
deleteDeveloperApiKey,
revealDeveloperApiKey,
}
export const listEnvironmentDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppDeploymentService_ListEnvironmentDeployments',
path: '/enterprise/app-instances/{appInstanceId}/environment-deployments',
tags: ['AppDeploymentService'],
})
.input(z.object({ params: zAppDeploymentServiceListEnvironmentDeploymentsPath }))
.output(zAppDeploymentServiceListEnvironmentDeploymentsResponse)
export const createDeployment = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeploymentService_CreateDeployment',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/deployments',
tags: ['AppDeploymentService'],
})
.input(
z.object({
body: zAppDeploymentServiceCreateDeploymentBody,
params: zAppDeploymentServiceCreateDeploymentPath,
}),
)
.output(zAppDeploymentServiceCreateDeploymentResponse)
export const cancelDeployment = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeploymentService_CancelDeployment',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/deployments/{deploymentId}/cancel',
tags: ['AppDeploymentService'],
})
.input(
z.object({
body: zAppDeploymentServiceCancelDeploymentBody,
params: zAppDeploymentServiceCancelDeploymentPath,
}),
)
.output(zAppDeploymentServiceCancelDeploymentResponse)
export const undeployRuntimeInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeploymentService_UndeployRuntimeInstance',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/undeploy',
tags: ['AppDeploymentService'],
})
.input(
z.object({
body: zAppDeploymentServiceUndeployRuntimeInstanceBody,
params: zAppDeploymentServiceUndeployRuntimeInstancePath,
}),
)
.output(zAppDeploymentServiceUndeployRuntimeInstanceResponse)
export const getDeploymentPlan = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppDeploymentService_GetDeploymentPlan',
path: '/enterprise/app-instances/{appInstanceId}/releases/{releaseId}/deployment-plan',
tags: ['AppDeploymentService'],
})
.input(z.object({ params: zAppDeploymentServiceGetDeploymentPlanPath }))
.output(zAppDeploymentServiceGetDeploymentPlanResponse)
export const appDeploymentService = {
listEnvironmentDeployments,
createDeployment,
cancelDeployment,
undeployRuntimeInstance,
getDeploymentPlan,
}
export const listReleases = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppReleaseService_ListReleases',
path: '/enterprise/app-instances/{appInstanceId}/releases',
tags: ['AppReleaseService'],
})
.input(
z.object({
params: zAppReleaseServiceListReleasesPath,
query: zAppReleaseServiceListReleasesQuery.optional(),
}),
)
.output(zAppReleaseServiceListReleasesResponse)
export const createRelease = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppReleaseService_CreateRelease',
path: '/enterprise/app-instances/{appInstanceId}/releases',
tags: ['AppReleaseService'],
})
.input(
z.object({
body: zAppReleaseServiceCreateReleaseBody,
params: zAppReleaseServiceCreateReleasePath,
}),
)
.output(zAppReleaseServiceCreateReleaseResponse)
export const previewRelease = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppReleaseService_PreviewRelease',
path: '/enterprise/app-instances/{appInstanceId}/releases/preview',
tags: ['AppReleaseService'],
})
.input(
z.object({
body: zAppReleaseServicePreviewReleaseBody,
params: zAppReleaseServicePreviewReleasePath,
}),
)
.output(zAppReleaseServicePreviewReleaseResponse)
export const appReleaseService = {
listReleases,
createRelease,
previewRelease,
}
export const oAuth2Login = oc
.route({
inputStructure: 'detailed',
@ -556,11 +133,6 @@ export const webAppAuth = {
}
export const contract = {
accessSubjectService,
appInstanceService,
appDeployAccessService,
appDeploymentService,
appReleaseService,
consoleSso,
webAppAuth,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -37,13 +37,6 @@ const stripSchemaNamePrefix = (schemaName: string) => {
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'EnterpriseAppDeployConsole')
return 'AppDeploy'
return tag || 'default'
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
@ -55,7 +48,7 @@ const contractNameSegments = (operation: ContractOperation) => {
}
const contractPathSegments = (operation: ContractOperation) => {
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
}
const normalizeEnterpriseOpenApi = () => {

View File

@ -11,6 +11,8 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props.
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
- Prefer Base UI data attributes and CSS variables for visual states; do not mirror state in React solely to add classes.
- When a Base UI API or selector contract is unclear, read the docs linked from `README.md` and the local `@base-ui/react` `.d.ts` files before coding.
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover

View File

@ -3,6 +3,7 @@
Shared UI primitives, design tokens, CSS-first Tailwind styles, and the `cn()` utility consumed by Dify's `web/` app.
The primitives are thin, opinionated wrappers around [Base UI] headless components, styled with `cva` + `cn` and Dify design tokens.
For upstream component docs, start from the [Base UI docs index].
> `private: true` — this package is consumed by `web/` via the pnpm workspace and is not published to npm. Treat the API as internal to Dify, but stable within the workspace.
@ -39,12 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./form`, `./field`, `./fieldset`, `./checkbox`, `./checkbox-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:
@ -174,5 +179,6 @@ See `[AGENTS.md](./AGENTS.md)` for:
[Base UI Fieldset]: https://base-ui.com/react/components/fieldset
[Base UI Form]: https://base-ui.com/react/components/form
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
[Base UI docs index]: https://base-ui.com/llms.txt
[Base UI]: https://base-ui.com/react
[Overlay & portal contract]: #overlay--portal-contract

View File

@ -65,6 +65,10 @@
"types": "./src/form/index.tsx",
"import": "./src/form/index.tsx"
},
"./input": {
"types": "./src/input/index.tsx",
"import": "./src/input/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"

View File

@ -30,7 +30,6 @@ export type AutocompleteRootHighlightEventDetails = BaseAutocomplete.Root.Highli
const autocompletePopupClassName = [
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
]
const autocompleteListClassName = [

View File

@ -273,6 +273,7 @@ describe('Combobox wrappers', () => {
it('should render item text indicator status and empty wrappers with design classes', async () => {
const screen = await renderSelectLikeCombobox({ open: true })
expect(screen.getByRole('option', { name: 'Workflow' }).element().className).not.toContain('mx-1')
await expect.element(screen.getByTestId('list').getByText('Workflow')).toHaveClass('system-sm-medium')
await expect.element(screen.getByTestId('status')).toHaveClass('text-text-tertiary')
await expect.element(screen.getByTestId('empty')).toHaveClass('system-sm-regular')

View File

@ -29,6 +29,11 @@ import {
useComboboxFilteredItems,
} from '.'
import { cn } from '../cn'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
type Option = {
value: string
@ -44,8 +49,6 @@ type OptionGroup = {
}
const fieldWidth = 'w-80'
const wideFieldWidth = 'w-[520px]'
const nativeFieldLabelClassName = 'mb-1 block text-text-secondary system-sm-medium'
type StoryVirtualizer = Virtualizer<HTMLDivElement, Element>
@ -174,11 +177,11 @@ const defaultDataSource = dataSourceOptions[0]!
const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[2]!]
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!, reviewerOptions[2]!, reviewerOptions[3]!]
const defaultTag = tagOptions[2]!
const renderOptionItem = (option: Option, index?: number) => (
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled}>
<ComboboxItem key={option.value} value={option} index={index} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0">
{option.icon && <span aria-hidden className={cn(option.icon, 'size-4 shrink-0 text-text-tertiary')} />}
<span className="min-w-0 flex-1">
@ -204,18 +207,20 @@ const PopupSearchInput = ({
label: string
placeholder: string
}) => (
<ComboboxInputGroup className="mb-1 border-divider-subtle bg-components-input-bg-normal">
<span aria-hidden className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput aria-label={label} placeholder={`${placeholder}`} className="pl-2" />
<ComboboxClear />
</ComboboxInputGroup>
<div className="p-1 pb-0">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput aria-label={label} placeholder={`${placeholder}`} className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0" />
</ComboboxInputGroup>
</div>
)
const GroupedToolList = () => {
const groups = useComboboxFilteredItems<OptionGroup>()
return (
<ComboboxList className="p-0">
<ComboboxList>
{groups.map((group, groupIndex) => (
<ComboboxGroup key={group.label} items={group.items}>
{groupIndex > 0 && <ComboboxSeparator />}
@ -318,7 +323,7 @@ const VirtualizedLongListDemo = () => {
<ComboboxTrigger aria-label="Model catalog">
<ComboboxValue placeholder="Select model" />
</ComboboxTrigger>
<ComboboxContent popupClassName="w-[440px] p-1">
<ComboboxContent popupClassName="w-[440px]">
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
<FilteredModelStatus />
<VirtualizedModelList virtualizerRef={virtualizerRef} />
@ -351,7 +356,8 @@ const AsyncDirectoryDemo = () => {
}, [inputValue])
return (
<div className={fieldWidth}>
<FieldRoot name="owner" className={fieldWidth}>
<FieldLabel>Owner</FieldLabel>
<Combobox
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
value={value}
@ -360,15 +366,12 @@ const AsyncDirectoryDemo = () => {
onInputValueChange={setInputValue}
autoHighlight
>
<label className={nativeFieldLabelClassName}>
Owner
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search owners…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput placeholder="Search owners…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
@ -377,12 +380,12 @@ const AsyncDirectoryDemo = () => {
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</div>
</FieldRoot>
)
}
const meta = {
title: 'Base/UI/Combobox',
title: 'Base/Form/Combobox',
component: Combobox,
parameters: {
layout: 'centered',
@ -398,24 +401,46 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
export const SelectLikeDefault: Story = {
export const Default: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={providerOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxLabel>Model provider</ComboboxLabel>
<ComboboxTrigger aria-label="Model provider">
<ComboboxValue placeholder="Select provider" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search model providers" placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<ComboboxLabel>Connect source</ComboboxLabel>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput placeholder="Search data sources…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const PopupInputSearchableSelect: Story = {
export const FormField: Story = {
render: () => (
<FieldRoot name="sourceConnector" className={fieldWidth}>
<FieldLabel>Connect source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput placeholder="Search data sources…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>Type to filter, then choose a remembered data source.</FieldDescription>
</FieldRoot>
),
}
export const CompactTriggerWithPopupSearch: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource} autoHighlight>
@ -423,9 +448,9 @@ export const PopupInputSearchableSelect: Story = {
<ComboboxTrigger aria-label="Data source">
<ComboboxValue placeholder="Choose source" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<ComboboxContent>
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
@ -436,38 +461,20 @@ export const AsyncSearchSingle: Story = {
render: () => <AsyncDirectoryDemo />,
}
export const InputGroupSearchable: Story = {
render: () => (
<div className={fieldWidth}>
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource} autoHighlight>
<label className={nativeFieldLabelClassName}>
Connect source
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search data sources…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
{(['small', 'medium', 'large'] as const).map(size => (
<Combobox key={size} items={sizeOptions} defaultValue={defaultProvider} autoHighlight>
<ComboboxTrigger aria-label={`${size} model provider`} size={size}>
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label={`Search ${size} model providers`} placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
<ComboboxLabel>{`${size[0]!.toUpperCase()}${size.slice(1)}`}</ComboboxLabel>
<ComboboxInputGroup size={size} className="px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput size={size} placeholder="Search providers…" className="px-1" />
<ComboboxClear size={size} className="mr-0.5" />
<ComboboxInputTrigger size={size} className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
))}
@ -483,7 +490,7 @@ export const Grouped: Story = {
<ComboboxTrigger aria-label="Workflow tool">
<ComboboxValue placeholder="Select tool" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<ComboboxContent>
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
<GroupedToolList />
</ComboboxContent>
@ -496,35 +503,34 @@ const MultipleChipsDemo = () => {
const [value, setValue] = useState<Option[]>(defaultReviewers)
return (
<div className={wideFieldWidth}>
<FieldRoot name="reviewers" className={fieldWidth}>
<FieldLabel>Reviewers</FieldLabel>
<Combobox items={reviewerOptions} multiple value={value} onValueChange={setValue} autoHighlight>
<label className={nativeFieldLabelClassName}>
Reviewers
<ComboboxInputGroup className="mt-1 h-auto min-h-8 flex-nowrap py-1">
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1 pr-1">
<ComboboxChips>
<ComboboxValue>
{(selectedValue: Option[]) => (
<>
<ComboboxChips className="flex-nowrap">
{selectedValue.map(item => (
<ComboboxChip key={item.value}>
<span className="max-w-32 truncate">{item.label}</span>
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
</ComboboxChip>
))}
</ComboboxChips>
<ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-16 px-2" />
{selectedValue.map(item => (
<ComboboxChip key={item.value}>
<span className="max-w-32 truncate">{item.label}</span>
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
</ComboboxChip>
))}
<ComboboxInput placeholder={selectedValue.length ? '' : 'Assign reviewers…'} className="min-w-24 px-1 py-0.5" />
</>
)}
</ComboboxValue>
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
</ComboboxChips>
<ComboboxClear className="mt-0.5 mr-0.5" />
<ComboboxInputTrigger className="mt-0.5 mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<FieldDescription>Selected reviewers wrap inside the input instead of scrolling horizontally.</FieldDescription>
</FieldRoot>
)
}
@ -538,53 +544,53 @@ export const VirtualizedLongList: Story = {
export const EmptyAndStatus: Story = {
render: () => (
<div className={fieldWidth}>
<FieldRoot name="connector" className={fieldWidth}>
<FieldLabel>Connector</FieldLabel>
<Combobox items={emptyOptions} defaultInputValue="salesforce" autoHighlight>
<label className={nativeFieldLabelClassName}>
Connector
<ComboboxInputGroup className="mt-1">
<span aria-hidden className="ml-3 i-ri-search-line size-4 shrink-0 text-text-tertiary" />
<ComboboxInput placeholder="Search connectors…" className="pl-2" />
<ComboboxClear />
<ComboboxInputTrigger />
</ComboboxInputGroup>
</label>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
<ComboboxInput placeholder="Search connectors…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxStatus>Search workspace connectors</ComboboxStatus>
<ComboboxEmpty>No connectors found</ComboboxEmpty>
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldRoot>
),
}
export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxLabel>Disabled provider</ComboboxLabel>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList className="p-0">{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<label className={nativeFieldLabelClassName}>
Read-only source
<ComboboxInputGroup className="mt-1">
<ComboboxInput placeholder="Read-only data source…" />
<ComboboxClear />
<ComboboxInputTrigger />
<FieldRoot name="disabledProvider" disabled>
<FieldLabel>Disabled provider</FieldLabel>
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
<ComboboxTrigger aria-label="Disabled model provider">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</FieldRoot>
<FieldRoot name="readOnlySource">
<FieldLabel>Read-only source</FieldLabel>
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<ComboboxInput placeholder="Read-only data source…" className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled" />
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
</label>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<ComboboxContent>
<ComboboxList>{renderOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</FieldRoot>
</div>
),
}
@ -594,16 +600,18 @@ const ControlledDemo = () => {
return (
<div className="flex w-80 flex-col items-start gap-3">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger aria-label="Default app tag">
<ComboboxValue placeholder="Select tag" />
</ComboboxTrigger>
<ComboboxContent popupClassName="p-1">
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList className="p-0">{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
<div className="w-full">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger aria-label="Default app tag">
<ComboboxValue placeholder="Select tag" />
</ComboboxTrigger>
<ComboboxContent>
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList>{renderSimpleOptionItem}</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 text-text-tertiary system-xs-regular">
Selected:
{' '}

View File

@ -31,7 +31,6 @@ export type ComboboxRootHighlightEventDetails = BaseCombobox.Root.HighlightEvent
const comboboxPopupClassName = [
'w-(--anchor-width) max-w-[min(28rem,var(--available-width))] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg outline-hidden',
'data-side-top:origin-bottom data-side-bottom:origin-top data-side-left:origin-right data-side-right:origin-left',
]
const comboboxListClassName = [
@ -40,7 +39,7 @@ const comboboxListClassName = [
]
const comboboxItemClassName = [
'mx-1 grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
'grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
'hover:bg-state-base-hover-alt hover:text-text-primary',
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
'data-selected:text-text-primary',
@ -51,7 +50,7 @@ const comboboxItemClassName = [
const comboboxTriggerVariants = cva(
[
'group/combobox-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden transition-colors',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
@ -101,7 +100,7 @@ export function ComboboxTrigger({
{children}
</span>
{icon !== false && (
<BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden">
<BaseCombobox.Icon className="shrink-0 text-text-quaternary transition-colors group-hover/combobox-trigger:text-text-secondary group-data-popup-open/combobox-trigger:text-text-secondary group-data-readonly/combobox-trigger:hidden">
{icon ?? <span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />}
</BaseCombobox.Icon>
)}
@ -115,7 +114,7 @@ const comboboxInputGroupVariants = cva(
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs',
'data-open:border-components-input-border-active data-open:bg-components-input-bg-active',
'data-popup-open:border-components-input-border-active data-popup-open:bg-components-input-bg-active',
'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled',
'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled',
'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal',

View File

@ -3,8 +3,8 @@
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import { textControlVariants } from '../text-control-variants'
export type FieldRootProps
= Omit<BaseFieldNS.Root.Props, 'className'>
@ -62,37 +62,11 @@ export function FieldLabel({
)
}
const fieldControlVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
export type FieldControlSize = NonNullable<VariantProps<typeof textControlVariants>['size']>
export type FieldControlProps
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
& VariantProps<typeof fieldControlVariants>
& VariantProps<typeof textControlVariants>
& {
className?: string
}
@ -106,7 +80,7 @@ export function FieldControl({
}: FieldControlProps) {
return (
<BaseField.Control
className={cn(fieldControlVariants({ size }), className)}
className={cn(textControlVariants({ size }), className)}
{...props}
/>
)

View File

@ -0,0 +1,83 @@
import { render } from 'vitest-browser-react'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '../../field'
import { Form } from '../../form'
import { Input } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Input', () => {
it('should render a labelled Base UI input with design-system classes', async () => {
const screen = await render(
<label>
Workspace name
<Input name="workspaceName" defaultValue="Dify" />
</label>,
)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
await expect.element(input).toHaveValue('Dify')
await expect.element(input).toHaveClass('rounded-lg', 'py-[7px]', 'system-sm-regular')
})
it('should apply size variants shared with FieldControl', async () => {
const screen = await render(
<>
<label>
Small input
<Input size="small" />
</label>
<div>
Large field
<FieldRoot name="largeField">
<FieldLabel>Large field</FieldLabel>
<FieldControl size="large" />
</FieldRoot>
</div>
</>,
)
await expect.element(screen.getByRole('textbox', { name: 'Small input' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
await expect.element(screen.getByRole('textbox', { name: 'Large field' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
})
it('should use FieldRoot invalid state', async () => {
const screen = await render(
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Repository URL</FieldLabel>
<Input defaultValue="github.com/langgenius" />
</FieldRoot>,
)
const input = screen.getByRole('textbox', { name: 'Repository URL' })
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
})
it('should integrate with FieldRoot and Base UI Form validation', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="account form" onFormSubmit={onFormSubmit}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" required />
<FieldError match="valueMissing">Email is required.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const input = screen.getByRole('textbox', { name: 'Email' })
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
})
expect(onFormSubmit).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Input } from './index'
const meta = {
title: 'Base/Form/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full FieldRoot form composition.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-name" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace name
</label>
<Input
id="workspace-name"
name="workspaceName"
autoComplete="organization"
placeholder="e.g. Acme workspace…"
/>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-80 gap-3">
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-input">
Small
<Input id="small-input" size="small" name="smallInput" placeholder="e.g. tag…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-input">
Medium
<Input id="medium-input" name="mediumInput" placeholder="e.g. Production API…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-input">
Large
<Input id="large-input" size="large" name="largeInput" placeholder="e.g. Customer portal…" autoComplete="off" />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="placeholder-state">Placeholder</label>
<Input id="placeholder-state" name="placeholderState" placeholder="e.g. Search datasets…" autoComplete="off" />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="filled-state">Filled</label>
<Input id="filled-state" name="filledState" defaultValue="Customer knowledge base" autoComplete="off" />
</div>
<div className="grid gap-1">
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Invalid</FieldLabel>
<Input
id="invalid-state"
type="url"
inputMode="url"
defaultValue="github.com/langgenius"
autoComplete="off"
spellCheck={false}
/>
<FieldError match>Enter a full URL including https://.</FieldError>
</FieldRoot>
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="disabled-state">Disabled</label>
<Input id="disabled-state" disabled name="disabledEmail" type="email" inputMode="email" placeholder="name@example.com…" autoComplete="email" spellCheck={false} />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="readonly-state">Read-only</label>
<Input id="readonly-state" readOnly name="endpoint" type="url" inputMode="url" defaultValue="https://api.example.com" autoComplete="url" spellCheck={false} />
</div>
</div>
),
}
export const WithField: Story = {
render: () => (
<Form aria-label="Account form" className="grid w-80 gap-4" onFormSubmit={() => undefined}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" inputMode="email" required autoComplete="email" placeholder="name@example.com…" spellCheck={false} />
<FieldDescription>Used for account notifications.</FieldDescription>
<FieldError match="valueMissing">Email is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
</FieldRoot>
<FieldRoot name="repositoryUrl">
<FieldLabel>Repository URL</FieldLabel>
<Input type="url" inputMode="url" required autoComplete="off" placeholder="https://github.com/langgenius/dify…" spellCheck={false} />
<FieldDescription>Use the full GitHub repository URL.</FieldDescription>
<FieldError match="valueMissing">Repository URL is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
</Form>
),
}

View File

@ -0,0 +1,31 @@
'use client'
import type { Input as BaseInputNS } from '@base-ui/react/input'
import type { VariantProps } from 'class-variance-authority'
import { Input as BaseInput } from '@base-ui/react/input'
import { cn } from '../cn'
import { textControlVariants } from '../text-control-variants'
export type InputSize = NonNullable<VariantProps<typeof textControlVariants>['size']>
export type InputProps
= Omit<BaseInputNS.Props, 'className' | 'size'>
& VariantProps<typeof textControlVariants>
& {
className?: string
}
export type InputChangeEventDetails = BaseInputNS.ChangeEventDetails
export function Input({
className,
size = 'medium',
...props
}: InputProps) {
return (
<BaseInput
className={cn(textControlVariants({ size }), className)}
{...props}
/>
)
}

View File

@ -174,7 +174,7 @@ describe('Select wrappers', () => {
it('should include open state feedback classes', async () => {
const screen = await renderOpenSelect()
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-open:bg-state-base-hover-alt')
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
})
})

View File

@ -21,7 +21,7 @@ export const SelectGroup = BaseSelect.Group
const selectTriggerVariants = cva(
[
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
@ -60,7 +60,7 @@ export function SelectTrigger({
<span className="min-w-0 grow truncate">
{children}
</span>
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-popup-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
</BaseSelect.Trigger>

View File

@ -0,0 +1,27 @@
import { cva } from 'class-variance-authority'
export const textControlVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)

View File

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ReactNode } from 'react'
import { toast } from '.'
import { toast, ToastHost } from '.'
const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover'
const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3'
@ -272,28 +272,31 @@ const UpdateExamples = () => {
const ToastDocsDemo = () => {
return (
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div className="space-y-3">
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">
Base UI toast docs
<>
<ToastHost />
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div className="space-y-3">
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">
Base UI toast docs
</div>
<h2 className="text-[24px] leading-8 font-semibold text-text-primary">
Shared stacked toast examples
</h2>
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior.
</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<VariantExamples />
<StackExamples />
<PromiseExamples />
<ActionExamples />
<UpdateExamples />
</div>
<h2 className="text-[24px] leading-8 font-semibold text-text-primary">
Shared stacked toast examples
</h2>
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior.
</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<VariantExamples />
<StackExamples />
<PromiseExamples />
<ActionExamples />
<UpdateExamples />
</div>
</div>
</div>
</>
)
}

View File

@ -99,6 +99,7 @@ export const PopoverTrigger = ({
...childProps,
'data-testid': childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'data-popup-open': open ? '' : undefined,
'onClick': (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
onClick?.(event)
@ -113,6 +114,7 @@ export const PopoverTrigger = ({
<div
data-testid="popover-trigger"
data-popover-trigger="true"
data-popup-open={open ? '' : undefined}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented)

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiArrowDownSLine } from '@remixicon/react'
import dayjs from 'dayjs'
@ -74,9 +73,9 @@ const RangeSelector: FC<Props> = ({
<SelectTrigger
className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', open && 'bg-state-base-hover-alt')}>
<div className="flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3 group-data-popup-open:bg-state-base-hover-alt">
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : selectedItem?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', open && 'text-text-secondary')} />
<RiArrowDownSLine className="size-4 text-text-quaternary group-data-popup-open:text-text-secondary" />
</div>
</SelectTrigger>
<SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1">

View File

@ -1,8 +0,0 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
export default async function InstanceDetailDeployPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeployTab appInstanceId={appInstanceId} />
}

View File

@ -1,15 +0,0 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default async function InstanceDetailLayout({ children, params }: {
children: ReactNode
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return (
<InstanceDetail appInstanceId={appInstanceId}>
{children}
</InstanceDetail>
)
}

View File

@ -1,8 +0,0 @@
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
export default async function InstanceDetailOverviewPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <OverviewTab appInstanceId={appInstanceId} />
}

View File

@ -1,8 +0,0 @@
import { redirect } from '@/next/navigation'
export default async function InstanceDetailPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
redirect(`/deployments/${appInstanceId}/overview`)
}

View File

@ -1,8 +0,0 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
export default async function InstanceDetailReleasesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <VersionsTab appInstanceId={appInstanceId} />
}

View File

@ -1,8 +0,0 @@
import { SettingsTab } from '@/features/deployments/detail/settings-tab'
export default async function InstanceDetailSettingsPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <SettingsTab appInstanceId={appInstanceId} />
}

View File

@ -1,12 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { CreateDeploymentGuide } from '@/features/deployments/create-guide'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CreateDeploymentPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.create'))
return <CreateDeploymentGuide />
}

View File

@ -1,10 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { DeploymentsList } from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
export default function DeploymentsPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.list'))
return <DeploymentsList />
}

View File

@ -6,7 +6,7 @@ import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import { Header } from '@/app/components/header'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'

View File

@ -1,6 +1,5 @@
import { screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import RoleRouteGuard from './role-route-guard'
const mockReplace = vi.fn()
@ -35,16 +34,6 @@ const setAppContext = (overrides: Partial<AppContextMock> = {}) => {
})
}
const renderRoleRouteGuard = (systemFeatures: { enable_app_deploy?: boolean } = {}) =>
renderWithSystemFeatures(
(
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
),
{ systemFeatures },
)
describe('RoleRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -57,7 +46,11 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
@ -69,7 +62,11 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
@ -83,7 +80,11 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
@ -95,30 +96,14 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect deployments routes when app deploy is disabled', async () => {
mockPathname = '/deployments'
renderRoleRouteGuard({ enable_app_deploy: false })
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should allow deployments routes when app deploy is enabled', () => {
mockPathname = '/deployments/app-1/overview'
renderRoleRouteGuard({ enable_app_deploy: true })
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@ -1,12 +1,10 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
@ -14,19 +12,15 @@ const isPathUnderRoute = (pathname: string, route: string) => pathname === route
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
const { isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const pathname = usePathname()
const router = useRouter()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirectDatasetOperator = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy
const shouldRedirect = shouldRedirectDatasetOperator || shouldRedirectAppDeploy
const redirectPath = shouldRedirectAppDeploy ? '/apps' : '/datasets'
const shouldRedirect = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
useEffect(() => {
if (shouldRedirect)
router.replace(redirectPath)
}, [redirectPath, shouldRedirect, router])
router.replace('/datasets')
}, [shouldRedirect, router])
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && isLoadingCurrentWorkspace)

View File

@ -49,7 +49,7 @@ const AppSidebarDropdown = ({ navigation, appInfoActions }: Props) => {
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
'data-popup-open:bg-background-default-hover',
)}
>
<AppIcon

View File

@ -63,7 +63,7 @@ const DatasetSidebarDropdown = ({
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
'data-popup-open:bg-background-default-hover',
)}
>
<AppIcon

View File

@ -109,7 +109,7 @@ export default function AddMemberOrGroupDialog() {
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-open:bg-state-accent-hover"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<RiAddCircleFill className="size-4" aria-hidden="true" />
<span>{t('operation.add', { ns: 'common' })}</span>

View File

@ -2,12 +2,12 @@ import type { FC } from 'react'
import type { VersionHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '../../base/input'
import Textarea from '../../base/textarea'
type VersionInfoModalProps = {
@ -57,10 +57,6 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onClose()
}
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}, [])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setReleaseNotes(e.target.value)
}, [])
@ -89,17 +85,16 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
</button>
</div>
<div className="flex flex-col gap-y-4 px-6 py-3">
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
<FieldRoot name="title" invalid={titleError} className="gap-y-1">
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
{t('versionHistory.editField.title', { ns: 'workflow' })}
</div>
<Input
</FieldLabel>
<FieldControl
value={title}
placeholder={`${t('versionHistory.nameThisVersion', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleTitleChange}
destructive={titleError}
onValueChange={setTitle}
/>
</div>
</FieldRoot>
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}

View File

@ -52,11 +52,11 @@ const VarPicker: FC<Props> = ({
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(triggerClassName)}>
<div className={cn('group', triggerClassName)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
'bg-transparent group-data-popup-open:bg-components-button-secondary-bg',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
@ -74,7 +74,7 @@ const VarPicker: FC<Props> = ({
</div>
)}
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'size-3.5')} />
<ChevronDownIcon className="size-3.5 group-data-popup-open:rotate-180 group-data-popup-open:text-text-tertiary" />
</div>
</div>
)}

View File

@ -71,10 +71,10 @@ const ModelInfo: FC<Props> = ({
<div className="relative">
<PopoverTrigger
render={(
<button type="button" className="block border-none bg-transparent p-0">
<button type="button" className="group block border-none bg-transparent p-0">
<div className={cn(
'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover',
open && 'bg-components-button-tertiary-bg-hover',
'group-data-popup-open:bg-components-button-tertiary-bg-hover',
)}
>
<RiInformation2Line className="size-4 text-text-tertiary" />

View File

@ -22,6 +22,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { toast } from '@langgenius/dify-ui/toast'
import {
Tooltip,
@ -30,11 +31,10 @@ import {
} from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -209,7 +209,6 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => {
const { t } = useTranslation()
const deleteAppNameInputId = useId()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
@ -631,8 +630,8 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('deleteAppConfirmContent', { ns: 'app' })}
</AlertDialogDescription>
<div className="mt-2">
<label htmlFor={deleteAppNameInputId} className="mb-1 block system-sm-regular text-text-secondary">
<FieldRoot name="confirm-app-name" className="mt-2">
<FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary">
<Trans
i18nKey="deleteAppConfirmInputLabel"
ns="app"
@ -641,19 +640,17 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
appName: <span className="system-sm-semibold text-text-primary" translate="no" />,
}}
/>
</label>
<Input
id={deleteAppNameInputId}
name="confirm-app-name"
</FieldLabel>
<FieldControl
type="text"
autoComplete="off"
spellCheck={false}
placeholder={t('deleteAppConfirmInputPlaceholder', { ns: 'app' })}
value={confirmDeleteInput}
onChange={e => setConfirmDeleteInput(e.target.value)}
onValueChange={setConfirmDeleteInput}
className="border-components-input-border-hover bg-components-input-bg-normal focus:border-components-input-border-active focus:bg-components-input-bg-active"
/>
</div>
</FieldRoot>
</div>
<AlertDialogActions>
<AlertDialogCancelButton type="button" disabled={isDeleting}>

View File

@ -22,6 +22,11 @@ export const inputVariants = cva(
},
)
/**
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
* a dedicated composition built on the primitive input.
*/
export type InputProps = {
showLeftIcon?: boolean
showClearIcon?: boolean
@ -36,6 +41,11 @@ export type InputProps = {
const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1')
/**
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
* a dedicated composition built on the primitive input.
*/
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size,
disabled,

View File

@ -95,6 +95,7 @@ export type CurrentPlanInfoBackend = {
}
webapp_copyright_enabled: boolean
workspace_members: {
enabled?: boolean
size: number
limit: number
}

View File

@ -153,7 +153,7 @@ export function DocumentPicker({
aria-label={value?.name || t('operation.search', { ns: 'common' })}
icon={false}
className={cn(
'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-open:bg-state-base-hover',
'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-popup-open:bg-state-base-hover',
)}
>
<ComboboxValue>

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'
import CrawledResult from '../base/crawled-result'
import CrawledResultItem from '../base/crawled-result-item'
import Header from '../base/header'
import Input from '../base/input'
import Input from '../base/text-input'
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Input from '../input'
import Input from '../text-input'
describe('WebsiteInput', () => {
const onChange = vi.fn()

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { Infotip } from '@/app/components/base/infotip'
import Input from './input'
import Input from './text-input'
type Props = {
className?: string

View File

@ -5,7 +5,7 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import Input from './input'
import Input from './text-input'
const I18N_PREFIX = 'stepOne.website'

View File

@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import Input from '../../base/input'
const I18N_PREFIX = 'stepOne.website'
@ -21,9 +21,6 @@ const UrlInput: FC<Props> = ({
const { t } = useTranslation()
const docLink = useDocLink()
const [url, setUrl] = useState('')
const handleUrlChange = useCallback((url: string | number) => {
setUrl(url as string)
}, [])
const handleOnRun = useCallback(() => {
if (isRunning)
return
@ -34,8 +31,9 @@ const UrlInput: FC<Props> = ({
<div className="flex items-center justify-between">
<Input
value={url}
onChange={handleUrlChange}
onValueChange={setUrl}
placeholder={docLink()}
size="small"
/>
<Button
variant="primary"

View File

@ -247,9 +247,8 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail,
'inline-flex items-center justify-center',
!isListScene && 'h-8! w-8! rounded-lg backdrop-blur-[5px]',
isOperationsMenuOpen
? 'shadow-none! hover:bg-state-base-hover!'
: isListScene && 'bg-transparent!',
isListScene && 'bg-transparent!',
'data-popup-open:shadow-none! data-popup-open:hover:bg-state-base-hover!',
)}
onClick={(e) => {
e.stopPropagation()

View File

@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Header } from '../index'
import Header from '../index'
function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
@ -44,10 +44,6 @@ vi.mock('@/app/components/header/tools-nav', () => ({
default: createMockComponent('tools-nav'),
}))
vi.mock('@/features/deployments/nav', () => ({
DeploymentsNav: createMockComponent('deployments-nav'),
}))
vi.mock('@/app/components/header/plan-badge', () => ({
PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
<button data-testid="plan-badge" onClick={onClick} data-plan={plan} />
@ -70,7 +66,6 @@ let mockPlanType = 'sandbox'
let mockBrandingEnabled = false
let mockBrandingTitle: string | null = null
let mockBrandingLogo: string | null = null
let mockEnableAppDeploy = false
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
@ -108,7 +103,6 @@ const renderHeader = (ui: ReactElement = <Header />) =>
application_title: mockBrandingTitle ?? '',
workspace_logo: mockBrandingLogo ?? '',
},
enable_app_deploy: mockEnableAppDeploy,
},
})
@ -123,7 +117,6 @@ describe('Header', () => {
mockBrandingEnabled = false
mockBrandingTitle = null
mockBrandingLogo = null
mockEnableAppDeploy = false
})
it('should render header with main nav components', () => {
@ -221,24 +214,6 @@ describe('Header', () => {
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
})
it('should hide deployments nav when app deploy is disabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = false
renderHeader()
expect(screen.queryByTestId('deployments-nav')).not.toBeInTheDocument()
})
it('should show deployments nav for editors when app deploy is enabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = true
renderHeader()
expect(screen.getByTestId('deployments-nav')).toBeInTheDocument()
})
it('should hide dataset nav when neither editor nor dataset operator', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false

View File

@ -113,7 +113,7 @@ function ModelSelector({
<ComboboxTrigger
aria-label={t('detailPanel.configureModel', { ns: 'plugin' })}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent"
disabled={readonly}
>
<ModelSelectorTrigger

View File

@ -1,5 +1,6 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -7,7 +8,6 @@ import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import { DeploymentsNav } from '@/features/deployments/nav'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@ -28,7 +28,7 @@ const navClassName = `
cursor-pointer
`
export function Header() {
const Header = () => {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -37,33 +37,29 @@ export function Header() {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
function handlePlanClick() {
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
function renderLogo() {
return (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
}
const renderLogo = () => (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
if (isMobile) {
return (
@ -77,17 +73,18 @@ export function Header() {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center gap-2">
<PluginsNav />
<div className="flex items-center">
<div className="mr-2">
<PluginsNav />
</div>
<AccountDropdown />
</div>
</div>
<div className="my-1 flex items-center justify-center gap-1">
<div className="my-1 flex items-center justify-center space-x-1">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
</div>
)
@ -103,18 +100,20 @@ export function Header() {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center space-x-2">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />
<PluginsNav />
<div className="mr-2">
<PluginsNav />
</div>
<AccountDropdown />
</div>
</div>
)
}
export default Header

View File

@ -134,7 +134,7 @@ export function AppPicker({
<ComboboxTrigger
aria-label={t('appSelector.label', { ns: 'app' })}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent"
>
{trigger}
</ComboboxTrigger>

View File

@ -43,7 +43,7 @@ const CategoriesFilter = ({
<div className={cn(
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
selectedTagsLength && 'text-text-secondary',
open && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
>
<div className={cn(

View File

@ -43,7 +43,7 @@ const TagsFilter = ({
<div className={cn(
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
selectedTagsLength && 'text-text-secondary',
open && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
>
<div className={cn(

View File

@ -47,7 +47,7 @@ function LabelSelector({
<PopoverTrigger
className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover',
'data-popup-open:bg-components-input-bg-hover data-popup-open:hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}>

View File

@ -196,8 +196,9 @@ describe('MethodSelector', () => {
await user.click(trigger)
await waitFor(() => {
const openTrigger = document.querySelector('.bg-background-section-burn\\!')
expect(openTrigger)!.toBeInTheDocument()
const openTrigger = screen.getByTestId('popover-trigger')
expect(openTrigger).toHaveAttribute('data-popup-open')
expect(openTrigger).toHaveClass('data-popup-open:bg-background-section-burn!')
})
})

View File

@ -36,7 +36,7 @@ const MethodSelector: FC<MethodSelectorProps> = ({
render={(
<div className={cn(
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
open && 'bg-background-section-burn! hover:bg-background-section-burn',
'data-popup-open:bg-background-section-burn! data-popup-open:hover:bg-background-section-burn',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>

View File

@ -74,17 +74,17 @@ const WorkflowChecklist = ({
<button
type="button"
className={cn(
'relative ml-0.5 flex size-7 items-center justify-center rounded-md border-none bg-transparent p-0',
'group relative ml-0.5 flex size-7 items-center justify-center rounded-md border-none bg-transparent p-0',
disabled && 'cursor-not-allowed opacity-50',
)}
disabled={disabled || undefined}
aria-label={checklistLabel}
>
<span
className={cn('group flex size-full items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
className="flex size-full items-center justify-center rounded-md group-data-popup-open:bg-state-accent-hover hover:bg-state-accent-hover"
>
<span
className={cn('i-ri-list-check-3 size-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
className="i-ri-list-check-3 size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text"
aria-hidden="true"
/>
</span>

View File

@ -79,7 +79,7 @@ const ViewHistory = ({
className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
'data-popup-open:bg-components-button-secondary-bg-hover',
)}
>
<span className="mr-1 i-custom-vender-line-time-clock-play size-4" />
@ -98,12 +98,12 @@ const ViewHistory = ({
<button
type="button"
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
className={cn('group flex size-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
className="group flex size-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
onClick={() => {
onClearLogAndMessageModal?.()
}}
>
<span className={cn('i-custom-vender-line-time-clock-play', 'size-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
<span className="i-custom-vender-line-time-clock-play size-4 text-components-button-ghost-text group-hover:text-components-button-secondary-accent-text group-data-popup-open:text-components-button-secondary-accent-text" />
</button>
)}
/>

View File

@ -157,7 +157,7 @@ const ViewWorkflowHistory = () => {
aria-label={t('changeHistory.title', { ns: 'workflow' })}
disabled={nodesReadOnly}
className={
cn('box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
cn('box-border inline-flex size-8 max-h-8 min-h-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary data-popup-open:bg-state-accent-active data-popup-open:text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
}
onClick={() => {
if (nodesReadOnly)

View File

@ -55,7 +55,7 @@ const ButtonStyleDropdown: FC<Props> = ({
>
<PopoverTrigger
render={(
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1 data-popup-open:bg-components-button-tertiary-bg-hover', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover')}>
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
<RiFontSize className="size-4" />
</Button>

View File

@ -28,6 +28,37 @@ type ProviderContextProviderProps = {
children: ReactNode
}
type MemberInviteLimit = {
size: number
limit: number
}
const unlimitedMemberInviteLimit: MemberInviteLimit = {
size: 0,
limit: 0,
}
const resolveMemberInviteLimit = (data: Awaited<ReturnType<typeof fetchCurrentPlanInfo>>): MemberInviteLimit => {
if (!data)
return unlimitedMemberInviteLimit
if (data.workspace_members?.enabled) {
return {
size: data.workspace_members.size,
limit: data.workspace_members.limit,
}
}
if (data.billing?.enabled && data.members?.limit > 0) {
return {
size: data.members.size,
limit: data.members.limit,
}
}
return unlimitedMemberInviteLimit
}
export const ProviderContextProvider = ({
children,
}: ProviderContextProviderProps) => {
@ -87,8 +118,7 @@ export const ProviderContextProvider = ({
setDatasetOperatorEnabled(true)
if (data.webapp_copyright_enabled)
setWebappCopyrightEnabled(true)
if (data.workspace_members)
setLicenseLimit({ workspace_members: data.workspace_members })
setLicenseLimit({ workspace_members: resolveMemberInviteLimit(data) })
if (data.is_allow_transfer_workspace)
setIsAllowTransferWorkspace(data.is_allow_transfer_workspace)
if (data.knowledge_pipeline?.publish_enabled)

View File

@ -48,10 +48,21 @@ const FLOATING_UI_RESTRICTED_IMPORT_PATTERNS = [
},
]
const LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'**/base/input',
'**/base/input/*',
],
message: 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.',
},
]
export const WEB_RESTRICTED_IMPORT_PATTERNS = [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...BASE_UI_RESTRICTED_IMPORT_PATTERNS,
...FLOATING_UI_RESTRICTED_IMPORT_PATTERNS,
...LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS,
]
export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = {

View File

@ -1,113 +0,0 @@
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { releaseDeploymentAction } from '../release-action'
function release(overrides: ReleaseRow): ReleaseRow {
return overrides
}
function currentRelease(overrides: ReleaseSummary): ReleaseSummary {
return overrides
}
describe('releaseDeploymentAction', () => {
describe('deploy actions', () => {
it('should return deploy when the target environment has no current release', () => {
// Arrange
const releases = [
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
releaseRows: releases,
})
// Assert
expect(action).toBe('deploy')
})
it('should return deployExistingRelease when a preset release is deployed to a new environment', () => {
// Arrange
const releases = [
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('deployExistingRelease')
})
})
describe('release direction', () => {
it('should return promote when the target release is newer than the current release', () => {
// Arrange
const releases = [
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
currentRelease: currentRelease({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('promote')
})
it('should return rollback when the target release is older than the current release', () => {
// Arrange
const releases = [
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[1],
currentRelease: currentRelease({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('rollback')
})
it('should fall back to release list order when release timestamps are unavailable', () => {
// Arrange
const releases = [
release({ id: 'release-3' }),
release({ id: 'release-2' }),
release({ id: 'release-1' }),
]
// Act
const rollbackAction = releaseDeploymentAction({
targetRelease: releases[2],
currentRelease: currentRelease({ id: 'release-2' }),
releaseRows: releases,
})
const promoteAction = releaseDeploymentAction({
targetRelease: releases[0],
currentRelease: currentRelease({ id: 'release-2' }),
releaseRows: releases,
})
// Assert
expect(rollbackAction).toBe('rollback')
expect(promoteAction).toBe('promote')
})
})
})

View File

@ -1,7 +0,0 @@
import { AppModeEnum } from '@/types/app'
const appModeValues = new Set<string>(Object.values(AppModeEnum))
export function toAppMode(mode?: string): AppModeEnum {
return appModeValues.has(mode ?? '') ? (mode as AppModeEnum) : AppModeEnum.WORKFLOW
}

View File

@ -1,349 +0,0 @@
'use client'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppTrigger({ open, app }: {
open: boolean
app?: App
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
app && 'py-1.5 pl-1.5',
)}
>
{app && (
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
)}
<span
title={app?.name}
className={cn(
'min-w-0 grow truncate',
app
? 'system-sm-medium text-components-input-text-filled'
: 'system-sm-regular text-components-input-text-placeholder',
)}
>
{app?.name ?? t('createModal.appPickerPlaceholder')}
</span>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
function SourceAppOption({ app }: {
app: App
}) {
const { t } = useTranslation('deployments')
const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<ComboboxItem
value={app}
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
>
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span title={`${app.name} (${app.id})`} className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
<span className="truncate">{app.name}</span>
<span className="shrink-0 text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</span>
</ComboboxItemText>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{modeLabel}</span>
</ComboboxItem>
)
}
function SourceAppPickerSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-3">
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-7 gap-3">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</SkeletonRow>
))}
</div>
)
}
function SourceAppPicker({ value, onChange }: {
value?: App
onChange: (app: App) => void
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APP_PAGE_SIZE,
name: searchText,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
items={apps}
open={isShow}
inputValue={searchText}
onOpenChange={setIsShow}
onInputValueChange={setSearchText}
onValueChange={(app) => {
if (!app)
return
onChange(app)
setIsShow(false)
}}
itemToStringLabel={app => app?.name ?? ''}
itemToStringValue={app => app?.id ?? ''}
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
disabled={false}
>
<ComboboxTrigger
aria-label={t('createModal.sourceApp')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger open={isShow} app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('createModal.appSearchPlaceholder')}
placeholder={t('createModal.appSearchPlaceholder')}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(isLoading || isFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{hasNextPage && (
<div className="flex justify-center px-3 py-2">
<Button
type="button"
size="small"
disabled={isFetchingNextPage}
onClick={() => {
void fetchNextPage()
}}
>
{isFetchingNextPage ? t('common.loading') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
</div>
</ComboboxContent>
</Combobox>
)
}
function CreateInstanceForm({ onClose }: {
onClose: () => void
}) {
const { t } = useTranslation('deployments')
const router = useRouter()
const createInstance = useMutation(consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions())
const [sourceApp, setSourceApp] = useState<App>()
const canCreate = Boolean(sourceApp?.id && !createInstance.isPending)
const handleCreate = async (form: HTMLFormElement) => {
if (!canCreate || !sourceApp?.id)
return
const formData = new FormData(form)
const name = String(formData.get('name') ?? '').trim()
const description = String(formData.get('description') ?? '').trim()
if (!name)
return
try {
const result = await createInstance.mutateAsync({
body: {
sourceAppId: sourceApp.id,
name: name.trim(),
description: description.trim() || undefined,
},
})
if (!result.appInstanceId)
throw new Error('Create app instance did not return an appInstanceId.')
onClose()
router.push(`/deployments/${result.appInstanceId}/overview`)
}
catch {
toast.error(t('createModal.createFailed'))
}
}
return (
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault()
void handleCreate(event.currentTarget)
}}
>
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('createModal.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('createModal.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary">{t('createModal.sourceApp')}</label>
<SourceAppPicker
value={sourceApp}
onChange={setSourceApp}
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-name">
{t('createModal.nameLabel')}
</label>
<Input
id="instance-name"
name="name"
type="text"
placeholder={sourceApp?.name ?? t('createModal.namePlaceholder')}
required
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-desc">
{t('createModal.descriptionLabel')}
</label>
<textarea
id="instance-desc"
name="description"
placeholder={t('createModal.descriptionPlaceholder')}
className="min-h-20 w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
{t('createModal.cancel')}
</Button>
<Button type="submit" variant="primary" disabled={!canCreate}>
{t('createModal.create')}
</Button>
</div>
</form>
)
}
export function CreateInstanceModal({ open, onOpenChange }: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className="w-130 max-w-[90vw]">
<DialogCloseButton />
{open && <CreateInstanceForm onClose={() => onOpenChange(false)} />}
</DialogContent>
</Dialog>
)
}

View File

@ -1,44 +0,0 @@
'use client'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
closeDeployDrawerAtom,
deployDrawerAppInstanceIdAtom,
deployDrawerEnvironmentIdAtom,
deployDrawerOpenAtom,
deployDrawerReleaseIdAtom,
} from '../store'
import { DeployForm } from './deploy-drawer/form'
export function DeployDrawer() {
const { t } = useTranslation('deployments')
const open = useAtomValue(deployDrawerOpenAtom)
const drawerAppInstanceId = useAtomValue(deployDrawerAppInstanceIdAtom)
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Dialog
open={open}
onOpenChange={next => !next && closeDeployDrawer()}
>
<DialogContent className="w-140 max-w-[90vw]">
<DialogCloseButton />
{!drawerAppInstanceId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -1,500 +0,0 @@
'use client'
import type { AppDeployEnvironment, DeploymentBindingSlot, DeploymentRuntimeBinding, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
import { environmentId, environmentMode, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { releaseDeploymentAction } from '../../release-action'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { closeDeployDrawerAtom } from '../../store'
import {
DeploymentSelect,
EnvironmentRow,
Field,
} from './select'
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: EnvironmentOption[]
releases: ReleaseRow[]
defaultReleaseId?: string
runtimeRows: EnvironmentDeployment[]
}
type EnvironmentOption = AppDeployEnvironment & { id: string }
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
type BindingSelections = Record<string, string>
type BindingSelectOption = {
value: string
label: string
}
type BindingOptionsPanelProps = {
slots: DeploymentBindingSlot[]
selections: BindingSelections
isLoading: boolean
hasError: boolean
onChange: (slot: string, value: string) => void
}
function isEnvBindingSlot(slot: DeploymentBindingSlot) {
return (slot.kind?.toLowerCase() ?? '').includes('env')
}
function bindingSlotKey(slot: DeploymentBindingSlot) {
return slot.slot ?? ''
}
function bindingCandidateOptions(slot: DeploymentBindingSlot): BindingSelectOption[] {
if (isEnvBindingSlot(slot)) {
return (slot.envVarCandidates ?? [])
.filter(candidate => candidate.envVarId)
.map(candidate => ({
value: candidate.envVarId!,
label: [
candidate.name,
candidate.displayValue,
].filter(Boolean).join(' · ') || candidate.envVarId!,
}))
}
return (slot.credentialCandidates ?? [])
.filter(candidate => candidate.credentialId)
.map(candidate => ({
value: candidate.credentialId!,
label: [
candidate.displayName,
candidate.pluginName || candidate.pluginId,
candidate.pluginVersion,
].filter(Boolean).join(' · ') || candidate.credentialId!,
}))
}
function hasMissingRequiredBinding(slot: DeploymentBindingSlot, selectedValue?: string) {
return Boolean(slot.required && !selectedValue)
}
function selectedDeploymentBindings(slots: DeploymentBindingSlot[], selections: BindingSelections): DeploymentRuntimeBinding[] {
return slots
.map((slot): DeploymentRuntimeBinding | undefined => {
const slotKey = bindingSlotKey(slot)
const selectedValue = selections[slotKey]
if (!slotKey || !selectedValue)
return undefined
return isEnvBindingSlot(slot)
? { slot: slotKey, envVarId: selectedValue }
: { slot: slotKey, credentialId: selectedValue }
})
.filter((binding): binding is DeploymentRuntimeBinding => Boolean(binding))
}
function selectedBindingSelections(slots: DeploymentBindingSlot[], manualBindings: BindingSelections): BindingSelections {
const next: BindingSelections = {}
for (const slot of slots) {
const slotKey = bindingSlotKey(slot)
const candidates = bindingCandidateOptions(slot)
const existing = manualBindings[slotKey]
if (existing && candidates.some(candidate => candidate.value === existing))
next[slotKey] = existing
else if (candidates.length === 1 && candidates[0])
next[slotKey] = candidates[0].value
}
return next
}
function BindingOptionsPanel({
slots,
selections,
isLoading,
hasError,
onChange,
}: BindingOptionsPanelProps) {
const { t } = useTranslation('deployments')
if (isLoading) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
)
}
if (hasError) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
{t('deployDrawer.bindingOptionsFailed')}
</div>
)
}
return (
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
<span className="system-xs-regular text-text-quaternary">{t('deployDrawer.bindingSelectionHint')}</span>
</div>
{slots.length === 0
? (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('deployDrawer.noBindingRequired')}
</div>
)
: slots.map((slot) => {
const slotKey = bindingSlotKey(slot)
const candidates = bindingCandidateOptions(slot)
const selectedValue = selections[slotKey] ?? ''
const missing = hasMissingRequiredBinding(slot, selectedValue)
return (
<div key={slotKey} className="flex flex-col gap-2 border-t border-divider-subtle px-3 py-3">
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] sm:items-start">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-secondary" title={slot.name || slotKey}>
{slot.name || slotKey}
</span>
{slot.required && (
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t('deployDrawer.requiredBinding')}
</span>
)}
</div>
<span className="font-mono system-xs-regular break-all text-text-quaternary" title={slotKey}>
{slotKey}
</span>
</div>
{candidates.length === 0
? (
<div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary">
{t('deployDrawer.noCredentialCandidates')}
</div>
)
: (
<DeploymentSelect
value={selectedValue}
onChange={value => onChange(slotKey, value)}
options={candidates}
placeholder={t('deployDrawer.selectCredential')}
/>
)}
</div>
{missing && (
<div className="system-xs-regular text-text-destructive">
{t('deployDrawer.missingRequiredBinding')}
</div>
)}
</div>
)
})}
</div>
)
}
function DeployFormSkeleton() {
return (
<div className="flex flex-col gap-5">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
</SkeletonContainer>
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => (
<SkeletonContainer key={key} className="gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
))}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
<SkeletonRow className="justify-end">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeployReadyForm({
appInstanceId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
runtimeRows,
}: DeployReadyFormProps) {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const startDeploy = useMutation(consoleQuery.enterprise.appDeploymentService.createDeployment.mutationOptions())
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
const displayedRelease: ReleaseRow | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
const isExistingRelease = Boolean(presetReleaseId)
const [selectedEnvId, setSelectedEnvId] = useState<string>(
() => lockedEnvId ?? environments[0]?.id ?? '',
)
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
const selectedEnvironment = environments.find(env => env.id === selectedEnvironmentId)
const [selectedReleaseId, setSelectedReleaseId] = useState<string>(
() => displayedRelease?.id ?? defaultReleaseId ?? '',
)
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId
const targetRelease = displayedRelease ?? selectedRelease ?? (targetReleaseId ? { id: targetReleaseId } : undefined)
const deploymentRows = runtimeRows.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row))
const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId)
const action = releaseDeploymentAction({
targetRelease,
currentRelease: selectedDeploymentRow?.currentRelease,
releaseRows: releases,
isExistingRelease,
})
const bindingOptions = useQuery(consoleQuery.enterprise.appDeploymentService.getDeploymentPlan.queryOptions({
input: {
params: {
appInstanceId,
releaseId: targetReleaseId || '',
},
},
enabled: Boolean(appInstanceId && targetReleaseId),
}))
const bindingSlots = bindingOptions.data?.plan?.slots?.filter(slot => slot.slot) ?? []
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
const selectedBindings = selectedBindingSelections(bindingSlots, manualBindings)
const deploymentBindings = selectedDeploymentBindings(bindingSlots, selectedBindings)
const bindingOptionsLoading = Boolean(targetReleaseId && (bindingOptions.isLoading || bindingOptions.isFetching))
const bindingOptionsReady = Boolean(targetReleaseId && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredBinding(slot, selectedBindings[bindingSlotKey(slot)]))
const isSubmitting = startDeploy.isPending
const canDeploy = Boolean(
selectedEnvironmentId
&& selectedEnvironment
&& targetReleaseId
&& bindingOptionsReady
&& requiredBindingsReady
&& !isSubmitting,
)
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
const actionTitle = action === 'rollback'
? t('deployDrawer.rollbackTitle')
: action === 'promote'
? t('deployDrawer.promoteTitle')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingReleaseTitle')
: t('deployDrawer.title')
const actionDescription = action === 'rollback'
? t('deployDrawer.rollbackDescription')
: action === 'promote'
? t('deployDrawer.promoteDescription')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingReleaseDescription')
: t('deployDrawer.description')
const submitLabel = isSubmitting
? t('deployDrawer.deploying')
: action === 'rollback'
? t('deployDrawer.rollback')
: action === 'promote'
? t('deployDrawer.promote')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingRelease')
: t('deployDrawer.deploy')
const handleDeploy = () => {
if (!canDeploy || !targetReleaseId)
return
startDeploy.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
bindings: deploymentBindings,
},
},
{
onSuccess: () => {
closeDeployDrawer()
},
onError: () => {
toast.error(t('deployDrawer.deployFailed'))
},
},
)
}
return (
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{actionTitle}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{actionDescription}
</DialogDescription>
</div>
<Field label={t('deployDrawer.releaseLabel')}>
{isExistingRelease && displayedRelease
? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(displayedRelease)}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">{displayedRelease.createdAt}</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.existingReleaseHint')}
</span>
</div>
)
: releases.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-3 py-3 system-sm-regular text-text-tertiary">
{t('deployDrawer.noReleaseAvailable')}
</div>
)
: (
<DeploymentSelect
value={selectedReleaseId}
onChange={setSelectedReleaseId}
options={releases.filter(release => release.id).map(release => ({
value: release.id!,
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
}))}
placeholder={t('deployDrawer.selectRelease')}
/>
)}
</Field>
<Field
label={t('deployDrawer.targetEnv')}
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
>
{lockedEnv
? <EnvironmentRow env={lockedEnv} />
: (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={setSelectedEnvId}
options={environments.filter(env => env.id).map(env => ({
value: env.id!,
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`,
}))}
placeholder={t('deployDrawer.selectEnv')}
/>
)}
</Field>
{targetReleaseId && (
<BindingOptionsPanel
slots={bindingSlots}
selections={selectedBindings}
isLoading={bindingOptionsLoading}
hasError={bindingOptions.isError}
onChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
/>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
{submitLabel}
</Button>
</div>
</div>
)
}
export function DeployForm({
appInstanceId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
if (releaseHistoryQuery.isLoading || runtimeInstancesQuery.isLoading) {
return <DeployFormSkeleton />
}
if (releaseHistoryQuery.isError || runtimeInstancesQuery.isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
</div>
)
}
const environments = runtimeInstancesQuery.data?.data
?.map(row => row.environment)
.filter((environment): environment is EnvironmentOption => Boolean(environment?.id)) ?? []
const releases = releaseHistoryQuery.data?.data?.filter(release => release.id) ?? []
const defaultReleaseId = releases[0]?.id
const runtimeRows = runtimeInstancesQuery.data?.data ?? []
const formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
return (
<DeployReadyForm
key={formKey}
appInstanceId={appInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={lockedEnvId}
presetReleaseId={presetReleaseId}
runtimeRows={runtimeRows}
/>
)
}

View File

@ -1,94 +0,0 @@
'use client'
import type { AppDeployEnvironment } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import { environmentHealth, environmentMode, environmentName } from '../../environment'
import { HealthBadge, ModeBadge } from '../status-badge'
type EnvironmentOption = AppDeployEnvironment & {
disabled?: boolean
}
export function Field({ label, hint, children }: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
}
type SelectOption = {
value: string
label: string
disabled?: boolean
disabledReason?: string
}
type SelectProps = {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
}
export function DeploymentSelect({ value, onChange, options, placeholder }: SelectProps) {
const { t } = useTranslation('deployments')
const selectedOption = options.find(option => option.value === value)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
className={cn(
'h-8 min-w-0 border border-components-input-border-active px-2 text-left system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(opt => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.disabled}
title={opt.disabled ? opt.disabledReason : undefined}
>
<SelectItemText>{opt.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function EnvironmentRow({ env }: { env: EnvironmentOption }) {
return (
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
</div>
)
}

View File

@ -1,68 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
type EnvironmentMode = 'shared' | 'isolated'
type EnvironmentHealth = 'ready' | 'degraded'
const statusStyles: Record<DeployStatus, string> = {
ready: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
deploy_failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
unknown: 'border-divider-subtle bg-background-default-subtle text-text-tertiary',
}
const statusKey = {
ready: 'status.ready',
deploying: 'status.deploying',
deploy_failed: 'status.deployFailed',
unknown: 'status.unknown',
} as const satisfies Record<DeployStatus, string>
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
export function StatusBadge({ status, className }: {
status: DeployStatus
className?: string
}) {
const { t } = useTranslation('deployments')
return (
<span className={cn(baseBadge, statusStyles[status], className)}>
{status === 'deploying' && (
<span className="size-1.5 animate-pulse rounded-full bg-current" />
)}
{t(statusKey[status])}
</span>
)
}
export function ModeBadge({ mode, className }: {
mode: EnvironmentMode
className?: string
}) {
const { t } = useTranslation('deployments')
const style = mode === 'shared'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(mode === 'shared' ? 'mode.shared' : 'mode.isolated')}
</span>
)
}
export function HealthBadge({ health, className }: {
health: EnvironmentHealth
className?: string
}) {
const { t } = useTranslation('deployments')
const style = health === 'ready'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(health === 'ready' ? 'health.ready' : 'health.degraded')}
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
import type { Pagination } from '@dify/contracts/enterprise/types.gen'
export const DEPLOYMENT_PAGE_SIZE = 100
export const RELEASE_HISTORY_PAGE_SIZE = 20
export const SOURCE_APPS_PAGE_SIZE = 100
export function getNextPageParamFromPagination(pagination?: Pagination) {
const currentPage = pagination?.currentPage ?? 1
const totalPages = pagination?.totalPages ?? 1
return currentPage < totalPages ? currentPage + 1 : undefined
}

View File

@ -1,113 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type SectionProps = {
title: string
description?: string
action?: ReactNode
children: ReactNode
layout?: 'block' | 'row'
tone?: 'default' | 'destructive'
}
export function SectionState({ children }: {
children: ReactNode
}) {
return (
<div className="flex min-h-24 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function DetailListState({ children }: {
children: ReactNode
}) {
return (
<div className="flex min-h-36 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function Section({
title,
description,
action,
children,
layout = 'block',
tone = 'default',
}: SectionProps) {
const titleClassName = cn(
'system-sm-semibold',
tone === 'destructive'
? 'text-util-colors-red-red-700'
: layout === 'row'
? 'text-text-secondary'
: 'text-text-primary',
)
const descriptionClassName = cn(
'mt-1 body-xs-regular',
tone === 'destructive' ? 'text-util-colors-red-red-600' : 'text-text-tertiary',
)
if (layout === 'row') {
return (
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={descriptionClassName}>
{description}
</p>
)}
</div>
<div className="min-w-0 grow">
{action
? (
<div className="flex min-w-0 items-start gap-3">
<div className="min-w-0 grow">
{children}
</div>
<div className="shrink-0">
{action}
</div>
</div>
)
: children}
</div>
</div>
</section>
)
}
return (
<section className="border-b border-divider-subtle py-6 first:pt-0 last:border-b-0 last:pb-0">
<div className="mb-3 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={cn(descriptionClassName, 'max-w-150')}>
{description}
</p>
)}
</div>
{Boolean(action) && (
<div className="shrink-0">
{action}
</div>
)}
</div>
<div className="min-w-0">
{children}
</div>
</section>
)
}

View File

@ -1,143 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval } from '../runtime-status'
import { openDeployDrawerAtom } from '../store'
import {
DetailListState,
} from './common'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import {
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
} from './list-styles'
function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
<Button
size="medium"
variant="primary"
className="gap-1.5"
onClick={() => openDeployDrawer({ appInstanceId })}
>
{t('deployTab.newDeployment')}
</Button>
)
}
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']
function DeploymentEnvironmentListSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className="flex flex-col gap-3 p-4">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-2.5 w-24 animate-pulse" />
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</div>
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
))}
</div>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.status')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div className="text-right">{t('deployTab.col.actions')}</div>
</div>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</div>
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</div>
<div className="min-w-0">
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</div>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
</div>
))}
</div>
</div>
</>
)
}
export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
}))
const environmentDeployments = environmentDeploymentsQuery.data
const rows = environmentDeployments?.data?.filter(row => row.environment?.id) ?? []
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-4 px-6 py-6">
<div className="flex items-center justify-between">
<div className="system-sm-semibold text-text-primary">
{t('deployTab.envCount')}
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{rows.length}
)
</span>
</div>
<NewDeploymentButton appInstanceId={appInstanceId} />
</div>
{isLoading
? <DeploymentEnvironmentListSkeleton />
: hasError
? <DetailListState>{t('common.loadFailed')}</DetailListState>
: rows.length === 0
? <DetailListState>{t('deployTab.empty')}</DetailListState>
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
)}
</div>
)
}

View File

@ -1,334 +0,0 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
environmentId,
environmentName,
} from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import {
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME,
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
} from '../list-styles'
import { DeploymentStatusSummary } from './deployment-status-summary'
function EnvironmentSummary({ environment }: {
environment: EnvironmentDeployment['environment']
}) {
return (
<span className="block truncate system-sm-semibold text-text-primary">
{environmentName(environment)}
</span>
)
}
function CurrentReleaseSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
if (!release?.id && !release?.name)
return <span className="system-sm-regular text-text-quaternary"></span>
const commit = releaseCommit(release)
return (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-baseline gap-1.5">
<span className="truncate font-mono system-sm-medium text-text-primary">
{releaseLabel(release)}
</span>
{commit !== '—' && (
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
)
}
function DeploymentRowActions({ appInstanceId, envId, row }: {
appInstanceId: string
envId: string
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.appDeploymentService.undeployRuntimeInstance.mutationOptions())
const isUndeployed = isUndeployedDeploymentRow(row)
const status = deploymentStatus(row)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [actionsOpen, setActionsOpen] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const undeployInFlightRef = useRef(false)
const isUndeployRequesting = undeployDeployment.isPending || isUndeploying
const undeployActionDisabled = isUndeployRequesting || !envId
const isDeploying = status === 'deploying'
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: status === 'deploy_failed'
? t('deployTab.viewError')
: t('deployTab.deployOtherVersion')
function handleDeployAction() {
openDeployDrawer({ appInstanceId, environmentId: envId })
setActionsOpen(false)
}
function handleUndeploy() {
if (!envId || undeployInFlightRef.current)
return
undeployInFlightRef.current = true
setIsUndeploying(true)
undeployDeployment.mutate(
{
params: { appInstanceId, environmentId: envId },
body: { appInstanceId, environmentId: envId },
},
{
onSettled: () => {
undeployInFlightRef.current = false
setIsUndeploying(false)
setShowUndeployConfirm(false)
},
},
)
}
return (
<div
className="flex shrink-0 items-center"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{!isDeploying && (
<DropdownMenu modal={false} open={actionsOpen} onOpenChange={setActionsOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{actionsOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleDeployAction}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
{!isUndeployed && (
<>
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<DropdownMenuItem
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3 text-util-colors-red-red-600',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (undeployActionDisabled)
return
setActionsOpen(false)
setShowUndeployConfirm(true)
}}
>
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
)}
{!isUndeployed && !isDeploying && (
<AlertDialog
open={showUndeployConfirm}
onOpenChange={(open) => {
if (isUndeployRequesting)
return
setShowUndeployConfirm(open)
}}
>
<AlertDialogContent className="w-130">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('deployTab.undeployConfirmTitle', { name: environmentName(row.environment) })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('deployTab.undeployConfirmDesc')}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={isUndeployRequesting}>
{t('deployDrawer.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isUndeployRequesting}
disabled={undeployActionDisabled}
onClick={handleUndeploy}
>
{t('deployTab.confirmUndeploy')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
function CurrentReleaseMobileSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
const { t } = useTranslation('deployments')
if (!release?.id && !release?.name)
return null
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('deployTab.col.currentRelease')}
</span>
<CurrentReleaseSummary release={release} />
</div>
)
}
function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
appInstanceId: string
row: EnvironmentDeployment
}) {
const envId = environmentId(row.environment)
const release = row.currentRelease
const status = deploymentStatus(row)
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
return (
<div className="border-b border-divider-subtle last:border-b-0">
<div className="flex flex-col gap-3 p-4 text-left">
<div className="flex min-w-0 flex-col gap-1">
<EnvironmentSummary environment={row.environment} />
<DeploymentStatusSummary row={row} />
</div>
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
<div className="flex min-w-0 items-center justify-start gap-2">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</div>
{showFailureBanner && (
<div className="flex items-center gap-2 border-l-2 border-util-colors-red-red-500 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
)}
</div>
)
}
function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
return (
<>
{rows.map((row, index) => {
const envId = environmentId(row.environment)
const status = deploymentStatus(row)
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
const isLast = index === rows.length - 1
return (
<div
key={envId}
className={DETAIL_LIST_ROW_CLASS_NAME}
>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<EnvironmentSummary environment={row.environment} />
</div>
<div className="min-w-0">
<DeploymentStatusSummary row={row} />
</div>
<div className="min-w-0">
<CurrentReleaseSummary release={row.currentRelease} />
</div>
<div className="flex w-8 justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</div>
{showFailureBanner && (
<div className={cn('flex items-center gap-2 border-t border-l-2 border-divider-subtle border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
)}
</div>
)
})}
</>
)
}
export function DeploymentEnvironmentList({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
const { t } = useTranslation('deployments')
return (
<>
<div className={cn(DETAIL_LIST_CLASS_NAME, 'pc:hidden')}>
{rows.map(row => (
<DeploymentEnvironmentMobileRow
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
/>
))}
</div>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.status')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div className="text-right">{t('deployTab.col.actions')}</div>
</div>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
</div>
</div>
</>
)
}

View File

@ -1,82 +0,0 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { releaseLabel } from '../../release'
import {
deploymentStatus,
isUndeployedDeploymentRow,
} from '../../runtime-status'
const StatusIconSlot = ({ children }: { children: ReactNode }) => {
return (
<span className="flex size-3 shrink-0 items-center justify-center">
{children}
</span>
)
}
export function DeploymentStatusSummary({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
if (isUndeployedDeploymentRow(row)) {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
<StatusIconSlot>
<span className="size-1.5 rounded-full bg-text-quaternary" />
</StatusIconSlot>
{t('status.notDeployed')}
</span>
)
}
const status = deploymentStatus(row)
if (status === 'deploying') {
const hasTargetRelease = !!(row.currentRelease?.name || row.currentRelease?.id)
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
<StatusIconSlot>
<span className="i-ri-loader-4-line size-2 animate-spin" />
</StatusIconSlot>
{hasTargetRelease
? t('deployTab.status.deployingRelease', { release: releaseLabel(row.currentRelease) })
: t('status.deploying')}
</span>
)
}
if (status === 'deploy_failed') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-red-red-700">
<StatusIconSlot>
<span className="i-ri-alert-line size-3" />
</StatusIconSlot>
{t(hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed')}
</span>
)
}
if (status === 'unknown') {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
<StatusIconSlot>
<span className="i-ri-question-line size-3" />
</StatusIconSlot>
{t('status.unknown')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
<StatusIconSlot>
<span className="size-1.5 rounded-full bg-util-colors-green-green-500" />
</StatusIconSlot>
{t('status.ready')}
</span>
)
}

View File

@ -1,269 +0,0 @@
'use client'
import type { ComponentProps, PropsWithoutRef } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useHover, useKeyPress, useLocalStorageState } from 'ahooks'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
type TabDef = {
key: InstanceDetailTabKey
icon: NavIcon
selectedIcon: NavIcon
}
type DeploymentSidebarMode = 'expand' | 'collapse'
const DEPLOYMENT_SIDEBAR_MODE_KEY = 'deployment-sidebar-collapse-or-expand'
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
title?: string
titleId?: string
}
function OverviewIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
}
function OverviewSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
}
function DeployIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-rocket-line', className)} />
}
function DeploySelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
}
function VersionsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-line', className)} />
}
function VersionsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-fill', className)} />
}
function SettingsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
}
function SettingsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
}
const TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
]
function isShortcutFromInputArea(target: EventTarget | null) {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
function useDeploymentSidebarMode(isMobile: boolean) {
const [persistedMode, setPersistedMode] = useLocalStorageState<DeploymentSidebarMode>(
DEPLOYMENT_SIDEBAR_MODE_KEY,
{ defaultValue: 'expand' },
)
const sidebarMode = isMobile ? 'collapse' : persistedMode ?? 'expand'
function toggleSidebarMode() {
setPersistedMode(sidebarMode === 'expand' ? 'collapse' : 'expand')
}
return {
sidebarMode,
toggleSidebarMode,
}
}
type DeploymentSidebarProps = {
appInstanceId: string
}
function DeploymentSidebarInstanceInfo({ appInstanceId, expand }: {
appInstanceId: string
expand: boolean
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
const app = overviewQuery.data?.overview?.appInstance
const isLoading = !app?.id && overviewQuery.isLoading
const isUnavailable = !app?.id || overviewQuery.isError
const instanceName = app?.name ?? appInstanceId
const appModeLabel = app?.id ? getAppModeLabel(toAppMode(app.mode), tCommon) : ''
const sourceAppLink = app?.sourceAppId && app.sourceAppAvailable !== false ? `/app/${app.sourceAppId}/overview` : undefined
const sourceAppName = app?.sourceAppName ?? t('detail.sourceApp')
return (
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
{isLoading
? (
<>
<SkeletonRectangle className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} />
{expand && (
<SkeletonContainer className="w-full gap-1">
<SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" />
</SkeletonContainer>
)}
</>
)
: isUnavailable
? (
<>
<div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface">
<span className="i-ri-rocket-line size-4" />
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary">
{t('detail.notFound')}
</div>
<div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary" title={appInstanceId}>
{appInstanceId}
</div>
</div>
)}
</>
)
: (
<>
<div className="flex items-center gap-1">
<AppIcon
size={expand ? 'large' : 'medium'}
iconType="emoji"
icon={app.icon}
background={app.iconBackground}
/>
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="flex max-w-full items-center gap-1.5 system-2xs-medium-uppercase text-text-tertiary">
<span className="shrink-0 whitespace-nowrap">{appModeLabel}</span>
{sourceAppLink && (
<>
<span aria-hidden className="shrink-0 text-text-quaternary">·</span>
<Link
href={sourceAppLink}
className="inline-flex min-w-0 items-center gap-0.5 rounded-sm text-text-tertiary transition-colors hover:text-text-secondary"
title={t('detail.openSourceApp', { name: sourceAppName })}
>
<span className="truncate">
{t('detail.sourceAppLink')}
</span>
<span aria-hidden className="i-ri-arrow-right-up-line size-3 shrink-0 text-text-quaternary" />
</Link>
</>
)}
</div>
{app.description && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={app.description}
>
{app.description}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
export function DeploymentSidebar({ appInstanceId }: DeploymentSidebarProps) {
const { t } = useTranslation('deployments')
const sidebarRef = useRef<HTMLDivElement>(null)
const isHoveringSidebar = useHover(sidebarRef)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { sidebarMode, toggleSidebarMode } = useDeploymentSidebarMode(isMobile)
const expand = sidebarMode === 'expand'
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
toggleSidebarMode()
}, { exactMatch: true, useCapture: true })
return (
<aside
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-54' : 'w-14',
)}
>
<DeploymentSidebarInstanceInfo appInstanceId={appInstanceId} expand={expand} />
<div className="relative px-4 py-2">
<Divider
type="horizontal"
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className="absolute -top-1 -right-3 z-20"
expand={expand}
handleToggle={toggleSidebarMode}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{TABS.map(tab => (
<NavLink
key={tab.key}
mode={sidebarMode}
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
name={t(`tabs.${tab.key}.name`)}
href={`/deployments/${appInstanceId}/${tab.key}`}
/>
))}
</nav>
</aside>
)
}

View File

@ -1,40 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { DeployDrawer } from '../components/deploy-drawer'
import { DeploymentSidebar } from './deployment-sidebar'
import { isInstanceDetailTabKey } from './tabs'
export function InstanceDetail({ appInstanceId, children }: {
appInstanceId: string
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
useDocumentTitle(t('documentTitle.detail'))
return (
<>
<div className="relative flex h-full min-w-0 overflow-hidden rounded-t-2xl shadow-xs">
<DeploymentSidebar appInstanceId={appInstanceId} />
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-y-0.5 px-6 pt-3 pb-2 2xl:max-w-[1440px]">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{children}
</div>
</div>
</div>
<DeployDrawer />
</>
)
}

View File

@ -1,14 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DETAIL_LIST_CLASS_NAME = 'overflow-hidden rounded-lg border border-divider-subtle bg-background-default'
export const DETAIL_LIST_ROW_CLASS_NAME = 'border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover'
export const DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
'disabled:cursor-not-allowed disabled:opacity-50',
)
export const DETAIL_LIST_HEADER_ROW_CLASS_NAME = 'grid min-h-8 items-center gap-6 border-b border-divider-subtle px-4 py-1.5 system-2xs-medium-uppercase text-text-tertiary'
export const DETAIL_LIST_DESKTOP_ROW_CLASS_NAME = 'grid min-h-12 items-center gap-6 px-4 py-2'
export const DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(160px,1fr)_minmax(150px,0.75fr)_minmax(180px,1fr)_auto]'
export const RELEASE_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(150px,1fr)_minmax(130px,0.75fr)_minmax(140px,0.8fr)_minmax(150px,1fr)_auto]'

View File

@ -1,160 +0,0 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { SectionState } from './common'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { computeOverviewStats } from './overview-tab/overview-drift'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
import { useSourceAppAvailability } from './source-app-availability'
const OVERVIEW_RELEASE_WINDOW = 20
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-6 px-6 py-6">
{children}
</div>
)
}
function SourceAppDeletedNotice() {
const { t } = useTranslation('deployments')
return (
<section
role="status"
className="flex items-start gap-3 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-4 py-3 text-util-colors-warning-warning-700"
>
<span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0" />
<div className="min-w-0">
<div className="system-sm-semibold text-util-colors-warning-warning-700">
{t('overview.sourceAppDeletedTitle')}
</div>
<p className="mt-1 system-sm-regular text-util-colors-warning-warning-700">
{t('overview.sourceAppDeletedDescription')}
</p>
</div>
</section>
)
}
function ReleaseOverviewSection({ appInstanceId, children }: {
appInstanceId: string
children: React.ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.recentReleases')}
</h3>
<Link
href={`/deployments/${appInstanceId}/releases`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
>
{t('overview.previousReleases.viewAll')}
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
</Link>
</div>
<div className="flex min-w-0 flex-col gap-3">
{children}
</div>
</section>
)
}
export function OverviewTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({ input }))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({ input }))
const releasesQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: { pageNumber: 1, resultsPerPage: OVERVIEW_RELEASE_WINDOW },
},
}))
const instance = overviewQuery.data?.overview?.appInstance
const sourceAppAvailability = useSourceAppAvailability(instance)
if (overviewQuery.isLoading) {
return (
<OverviewLayout>
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHeroSkeleton />
</ReleaseOverviewSection>
</OverviewLayout>
)
}
if (overviewQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
if (!instance?.id) {
return (
<OverviewLayout>
<SectionState>{t('detail.notFound')}</SectionState>
</OverviewLayout>
)
}
if (releasesQuery.isLoading) {
return (
<OverviewLayout>
{sourceAppAvailability.sourceAppUnavailable && <SourceAppDeletedNotice />}
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHeroSkeleton />
</ReleaseOverviewSection>
<EnvironmentStripSkeleton />
</OverviewLayout>
)
}
if (releasesQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
const releaseRows = releasesQuery.data?.data ?? []
const runtimeRows = runtimeInstancesQuery.data?.data?.filter(row => row.environment?.id) ?? []
const latestRelease = releaseRows[0]
const stats = computeOverviewStats(runtimeRows, releaseRows)
return (
<OverviewLayout>
{sourceAppAvailability.sourceAppUnavailable && <SourceAppDeletedNotice />}
<div className="flex min-w-0 flex-col gap-6">
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHero
appInstanceId={appInstanceId}
latestRelease={latestRelease}
stats={stats}
/>
</ReleaseOverviewSection>
<EnvironmentStrip
appInstanceId={appInstanceId}
rows={runtimeRows}
releaseRows={releaseRows}
stats={stats}
isLoading={runtimeInstancesQuery.isLoading}
isError={runtimeInstancesQuery.isError}
/>
</div>
</OverviewLayout>
)
}

View File

@ -1,180 +0,0 @@
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { computeDrift, computeOverviewStats, latestReleaseId } from '../overview-drift'
function row(overrides: EnvironmentDeployment): EnvironmentDeployment {
return overrides
}
function release(overrides: ReleaseRow): ReleaseRow {
return overrides
}
describe('computeDrift', () => {
it('should return undeployed when the runtime row signals undeployed', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1', name: 'prod' },
status: 'undeployed',
})
// Act
const result = computeDrift(runtime, [])
// Assert
expect(result).toEqual({ kind: 'undeployed' })
})
it('should return undeployed when there is no id, current release, or detail', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1', name: 'prod' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-1' })])
// Assert
expect(result).toEqual({ kind: 'undeployed' })
})
it('should return unknown when the deployed release has no id', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1', replicas: 1 },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-1' })])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
it('should return unknown when the deployed release is not in the loaded window', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-older' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-3' }), release({ id: 'r-2' })])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
it('should return up-to-date when the deployed release is the newest in the window', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-3' },
})
// Act
const result = computeDrift(runtime, [
release({ id: 'r-3' }),
release({ id: 'r-2' }),
release({ id: 'r-1' }),
])
// Assert
expect(result).toEqual({ kind: 'up-to-date' })
})
it('should return behind with the index distance when the deployed release is older', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-1' },
})
// Act
const result = computeDrift(runtime, [
release({ id: 'r-3' }),
release({ id: 'r-2' }),
release({ id: 'r-1' }),
])
// Assert
expect(result).toEqual({ kind: 'behind', steps: 2 })
})
it('should return unknown when the release window is empty', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-1' },
})
// Act
const result = computeDrift(runtime, [])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
})
describe('latestReleaseId', () => {
it('should return the first release id', () => {
expect(latestReleaseId([release({ id: 'r-3' }), release({ id: 'r-2' })])).toBe('r-3')
})
it('should return undefined when the list is empty', () => {
expect(latestReleaseId([])).toBeUndefined()
})
it('should return undefined when the first release has no id', () => {
expect(latestReleaseId([release({})])).toBeUndefined()
})
})
describe('computeOverviewStats', () => {
const releases = [release({ id: 'r-3' }), release({ id: 'r-2' }), release({ id: 'r-1' })]
it('should classify each row into a single bucket', () => {
// Arrange
const rows: EnvironmentDeployment[] = [
row({ runtime: { runtimeInstanceId: 'rt-1' }, environment: { id: 'env-1' }, status: 'ready', currentRelease: { id: 'r-3' } }),
row({ runtime: { runtimeInstanceId: 'rt-2' }, environment: { id: 'env-2' }, status: 'ready', currentRelease: { id: 'r-1' } }),
row({ runtime: { runtimeInstanceId: 'rt-3' }, environment: { id: 'env-3' }, status: 'deploying', currentRelease: { id: 'r-3' } }),
row({ runtime: { runtimeInstanceId: 'rt-4' }, environment: { id: 'env-4' }, status: 'deploy_failed', currentRelease: { id: 'r-2' } }),
row({ environment: { id: 'env-5' }, status: 'undeployed' }),
]
// Act
const stats = computeOverviewStats(rows, releases)
// Assert
expect(stats).toEqual({ total: 5, ready: 1, behind: 1, failed: 1, deploying: 1, undeployed: 1 })
})
it('should not count failed envs as behind even when on an older release', () => {
// Arrange
const rows: EnvironmentDeployment[] = [
row({ runtime: { runtimeInstanceId: 'rt-1' }, environment: { id: 'env-1' }, status: 'deploy_failed', currentRelease: { id: 'r-1' } }),
]
// Act
const stats = computeOverviewStats(rows, releases)
// Assert
expect(stats.failed).toBe(1)
expect(stats.behind).toBe(0)
})
it('should return all zeros for an empty grid', () => {
expect(computeOverviewStats([], releases)).toEqual({ total: 0, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 })
})
})

View File

@ -1,177 +0,0 @@
'use client'
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { environmentId, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentChipProps = {
appInstanceId: string
row: EnvironmentDeployment
releaseRows: ReleaseRow[]
}
type ChipKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
type ChipConfig = {
kind: ChipKind
dotClass: string
suffixClass: string
showRelease: boolean
intent: 'drawer' | 'navigate' | 'disabled'
releaseId?: string
}
export function EnvironmentChip({ appInstanceId, row, releaseRows }: EnvironmentChipProps) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const router = useRouter()
const envId = environmentId(row.environment)
const drift = computeDrift(row, releaseRows)
const status = deploymentStatus(row)
const latestId = latestReleaseId(releaseRows)
const hasAnyRelease = releaseRows.length > 0
const currentReleaseId = row.currentRelease?.id
const config = resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId })
const suffix = renderSuffix(config.kind, drift, t)
const showRelease = config.showRelease && Boolean(row.currentRelease?.id)
const isDisabled = config.intent === 'disabled'
const tooltip = isDisabled ? t('overview.chip.needsReleaseFirst') : config.intent === 'navigate' ? t('overview.chip.openInDeployTab') : undefined
function handleClick() {
if (config.intent === 'disabled')
return
if (config.intent === 'navigate') {
router.push(`/deployments/${appInstanceId}/deploy`)
return
}
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
}
return (
<button
type="button"
disabled={isDisabled}
title={tooltip}
onClick={handleClick}
className={cn(
'inline-flex max-w-[280px] items-center gap-2 rounded-full border px-3 py-1.5 system-xs-medium transition-colors',
'border-divider-subtle bg-components-panel-bg text-text-secondary',
'hover:bg-state-base-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-components-panel-bg',
)}
>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
<span className="truncate text-text-primary">{environmentName(row.environment)}</span>
{showRelease && (
<span className="flex shrink-0 items-baseline gap-1 font-mono text-text-tertiary">
<span>{releaseLabel(row.currentRelease)}</span>
<span className="system-2xs-regular">{releaseCommit(row.currentRelease)}</span>
</span>
)}
<span aria-hidden className="text-text-quaternary">·</span>
<span className={cn('shrink-0', config.suffixClass)}>{suffix}</span>
</button>
)
}
function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: {
drift: ReturnType<typeof computeDrift>
status: ReturnType<typeof deploymentStatus>
hasAnyRelease: boolean
latestId: string | undefined
currentReleaseId: string | undefined
}): ChipConfig {
if (status === 'deploying') {
return {
kind: 'deploying',
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
suffixClass: 'text-util-colors-blue-blue-700',
showRelease: false,
intent: 'navigate',
}
}
if (status === 'deploy_failed') {
return {
kind: 'failed',
dotClass: 'bg-util-colors-red-red-500',
suffixClass: 'text-util-colors-red-red-700',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId ?? latestId,
}
}
if (drift.kind === 'undeployed') {
return {
kind: 'empty',
dotClass: 'bg-text-quaternary',
suffixClass: 'text-text-quaternary',
showRelease: false,
intent: hasAnyRelease ? 'drawer' : 'disabled',
releaseId: latestId,
}
}
if (drift.kind === 'up-to-date') {
return {
kind: 'latest',
dotClass: 'bg-util-colors-green-green-500',
suffixClass: 'text-util-colors-green-green-700',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId,
}
}
if (drift.kind === 'behind') {
return {
kind: 'behind',
dotClass: 'bg-util-colors-green-green-500',
suffixClass: 'text-util-colors-warning-warning-700',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
return {
kind: 'older',
dotClass: 'bg-util-colors-green-green-500',
suffixClass: 'text-text-tertiary',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
function renderSuffix(
kind: ChipKind,
drift: ReturnType<typeof computeDrift>,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
return t('overview.chip.empty')
case 'latest':
return t('overview.chip.latest')
case 'behind':
return t('overview.chip.behind', { count: drift.kind === 'behind' ? drift.steps : 0 })
case 'older':
return t('overview.chip.olderRelease')
case 'deploying':
return t('overview.chip.deploying')
case 'failed':
return t('overview.chip.failed')
}
}

Some files were not shown because too many files have changed in this diff Show More