mirror of
https://github.com/langgenius/dify.git
synced 2026-05-22 01:48:39 +08:00
Compare commits
4 Commits
4-27-app-d
...
fix/member
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f0da07318 | |||
| 6ed98e180d | |||
| 7f633622aa | |||
| 66f5ab4cfc |
@ -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
|
||||
|
||||
|
||||
1
.github/workflows/build-push.yml
vendored
1
.github/workflows/build-push.yml
vendored
@ -9,7 +9,6 @@ on:
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-backend"
|
||||
- "4-27-app-deploy"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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]
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
@ -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 = () => {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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:
|
||||
{' '}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
83
packages/dify-ui/src/input/__tests__/index.spec.tsx
Normal file
83
packages/dify-ui/src/input/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
124
packages/dify-ui/src/input/index.stories.tsx
Normal file
124
packages/dify-ui/src/input/index.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
31
packages/dify-ui/src/input/index.tsx
Normal file
31
packages/dify-ui/src/input/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
27
packages/dify-ui/src/text-control-variants.ts
Normal file
27
packages/dify-ui/src/text-control-variants.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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`)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -95,6 +95,7 @@ export type CurrentPlanInfoBackend = {
|
||||
}
|
||||
webapp_copyright_enabled: boolean
|
||||
workspace_members: {
|
||||
enabled?: boolean
|
||||
size: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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!')}>
|
||||
|
||||
@ -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!')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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')}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
@ -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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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]'
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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
Reference in New Issue
Block a user